Message List: Move streaming responses to the top (#6924)

This commit is contained in:
zubiden 2026-06-01 01:15:47 +02:00 committed by Alexander Zinchuk
parent a103f09dae
commit 2b6fca15bc
12 changed files with 332 additions and 113 deletions

View File

@ -800,6 +800,7 @@ export interface ApiMessage {
fromRank?: string; fromRank?: string;
isTypingDraft?: boolean; // Local field isTypingDraft?: boolean; // Local field
wasTypingDraft?: boolean; // Local field
} }
export interface ApiReactions { export interface ApiReactions {

View File

@ -1,6 +1,7 @@
@use "../../styles/mixins"; @use "../../styles/mixins";
.MessageList { .MessageList {
container-type: size;
overflow-x: hidden; overflow-x: hidden;
overflow-y: scroll; overflow-y: scroll;
flex: 1; flex: 1;
@ -59,6 +60,18 @@
scroll-snap-align: end; 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) { @media (max-width: 600px) {
width: 100vw; width: 100vw;
// Patch for an issue on Android when rotating device // 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); }
}

View File

@ -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 { addExtraClass, removeExtraClass } from '@teact/teact-dom';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
@ -11,6 +11,7 @@ import {
ANIMATION_END_DELAY, ANIMATION_END_DELAY,
ANONYMOUS_USER_ID, ANONYMOUS_USER_ID,
MESSAGE_LIST_SLICE, MESSAGE_LIST_SLICE,
SCROLL_MAX_DURATION,
SERVICE_NOTIFICATIONS_USER_ID, SERVICE_NOTIFICATIONS_USER_ID,
} from '../../config'; } from '../../config';
import { forceMeasure, requestMeasure, requestMutation } from '../../lib/fasterdom/fasterdom'; import { forceMeasure, requestMeasure, requestMutation } from '../../lib/fasterdom/fasterdom';
@ -180,6 +181,7 @@ const BOTTOM_SNAP_THRESHOLD = 7;
const UNREAD_DIVIDER_TOP = 10; const UNREAD_DIVIDER_TOP = 10;
const SCROLL_DEBOUNCE = 200; const SCROLL_DEBOUNCE = 200;
const MESSAGE_ANIMATION_DURATION = 500; const MESSAGE_ANIMATION_DURATION = 500;
const SEND_FOCUS_DURATION = SCROLL_MAX_DURATION + ANIMATION_END_DELAY;
const BOTTOM_FOCUS_MARGIN = 0.5 * REM; const BOTTOM_FOCUS_MARGIN = 0.5 * REM;
const SELECT_MODE_ANIMATION_DURATION = 200; 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); 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 = ({ const MessageList = ({
chatId, chatId,
threadId, threadId,
@ -276,7 +284,11 @@ const MessageList = ({
const isReplacingHistoryRef = useRef(false); const isReplacingHistoryRef = useRef(false);
const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage)); const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage));
const scrollSnapDisabledTimerRef = useRef<number>(); const scrollSnapDisabledTimerRef = useRef<number>();
const typingDraftSnapTriggeredIdRef = useRef<number>(); const isLiveTailBottomSnapSuppressedRef = useRef(false);
const isLiveTailAutoScrollingRef = useRef(false);
const liveTailReleaseTimerRef = useRef<number>();
const liveTailStartOriginalIdRef = useRef<number>();
const [releasedLiveTailStartOriginalId, setReleasedLiveTailStartOriginalId] = useState<number>();
const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId);
const hasOpenChatButton = isSavedDialog const hasOpenChatButton = isSavedDialog
@ -289,6 +301,42 @@ const MessageList = ({
const withUsers = Boolean((!isPrivate && !isChannelChat) const withUsers = Boolean((!isPrivate && !isChannelChat)
|| isChatWithSelf || isSystemBotChat || isAnonymousForwards || isChannelWithAvatars); || 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(() => { useSyncEffect(() => {
// We only need it first time when message list appears // We only need it first time when message list appears
if (areMessagesLoaded) { if (areMessagesLoaded) {
@ -399,19 +447,22 @@ const MessageList = ({
!isForum ? Number(threadId) : undefined, !isForum ? Number(threadId) : undefined,
isChatWithSelf, isChatWithSelf,
withUsers, withUsers,
effectiveLiveTailStartOriginalId,
) )
: undefined; : undefined;
}, [withUsers, }, [withUsers,
messageIds, messagesById, type, messageIds, messagesById, type,
isServiceNotificationsChat, isForum, isServiceNotificationsChat, isForum,
threadId, isChatWithSelf, channelJoinInfo]); threadId, isChatWithSelf, channelJoinInfo, effectiveLiveTailStartOriginalId]);
const currentLastMessageOriginalId = useMemo(() => { const currentLastMessageId = messageIds?.[messageIds.length - 1];
const currentLastMessageId = messageIds?.[messageIds.length - 1]; const currentLastMessage = currentLastMessageId !== undefined ? messagesById?.[currentLastMessageId] : undefined;
const currentLastMessage = currentLastMessageId !== undefined ? messagesById?.[currentLastMessageId] : undefined; const currentLastMessageOriginalId = currentLastMessage
? getMessageOriginalId(currentLastMessage)
return currentLastMessage ? getMessageOriginalId(currentLastMessage) : currentLastMessageId; : currentLastMessageId;
}, [messageIds, messagesById]); const isCurrentLastMessageTypingDraft = Boolean(
currentLastMessage?.isTypingDraft || currentLastMessage?.wasTypingDraft,
);
useInterval(() => { useInterval(() => {
if (!messageIds || !messagesById || type === 'scheduled' || isAccountFrozen || !isActive) return; if (!messageIds || !messagesById || type === 'scheduled' || isAccountFrozen || !isActive) return;
@ -494,6 +545,13 @@ const MessageList = ({
const bottomTrigger = container?.querySelector<HTMLDivElement>('.fab-trigger'); const bottomTrigger = container?.querySelector<HTMLDivElement>('.fab-trigger');
if (!container || !bottomTrigger) return; 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 // Check if fab-trigger + threshold are entering the viewport
const viewportBottom = container.scrollTop + container.offsetHeight; const viewportBottom = container.scrollTop + container.offsetHeight;
const triggerPosition = bottomTrigger.offsetTop; const triggerPosition = bottomTrigger.offsetTop;
@ -517,29 +575,13 @@ const MessageList = ({
} }
}); });
const handleTallTypingDraft = useLastCallback((messageId: number, isNearExit: boolean) => { const allowLiveTailBottomSnap = useLastCallback(() => {
if (!isNearExit) { if (effectiveLiveTailStartOriginalId === undefined || !isLiveTailBottomSnapSuppressedRef.current) {
if (typingDraftSnapTriggeredIdRef.current === messageId) {
typingDraftSnapTriggeredIdRef.current = undefined;
}
return; return;
} }
if (typingDraftSnapTriggeredIdRef.current === messageId) { isLiveTailBottomSnapSuppressedRef.current = false;
return; updateBottomSnapClass();
}
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);
});
}); });
const handleScroll = useLastCallback(() => { const handleScroll = useLastCallback(() => {
@ -557,6 +599,16 @@ const MessageList = ({
updateStickyDates(container); 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 // Check if scroll should be snapped, but only if there's no new message animation in progress
if (scrollSnapDisabledTimerRef.current === undefined) { if (scrollSnapDisabledTimerRef.current === undefined) {
updateBottomSnapClass(); updateBottomSnapClass();
@ -585,6 +637,11 @@ const MessageList = ({
const [getContainerHeight, prevContainerHeightRef] = useContainerHeight(containerRef, canPost && !isSelectModeActive); const [getContainerHeight, prevContainerHeightRef] = useContainerHeight(containerRef, canPost && !isSelectModeActive);
const handleWheel = useLastCallback((e: React.WheelEvent<HTMLDivElement>) => { const handleWheel = useLastCallback((e: React.WheelEvent<HTMLDivElement>) => {
if (e.deltaY > 0) {
isLiveTailAutoScrollingRef.current = false;
allowLiveTailBottomSnap();
}
// Remove snap when scrolling up to avoid scroll bug // Remove snap when scrolling up to avoid scroll bug
// https://bugzilla.mozilla.org/show_bug.cgi?id=1753188 // https://bugzilla.mozilla.org/show_bug.cgi?id=1753188
if (IS_FIREFOX && e.deltaY < 0) { if (IS_FIREFOX && e.deltaY < 0) {
@ -644,7 +701,7 @@ const MessageList = ({
forceMeasure(() => rememberScrollPositionRef.current()); forceMeasure(() => rememberScrollPositionRef.current());
}, },
// This will run before modifying content and should match deps for `useLayoutEffectWithPrevDeps` below // This will run before modifying content and should match deps for `useLayoutEffectWithPrevDeps` below
[messageIds, isViewportNewest, rememberScrollPositionRef], [messageIds, isViewportNewest, effectiveLiveTailStartOriginalId, rememberScrollPositionRef],
); );
useEffect( useEffect(
() => rememberScrollPositionRef.current(), () => rememberScrollPositionRef.current(),
@ -653,7 +710,9 @@ const MessageList = ({
); );
// Handles updated message list, takes care of scroll repositioning // Handles updated message list, takes care of scroll repositioning
useLayoutEffectWithPrevDeps(([prevMessageIds, prevIsViewportNewest, prevCurrentLastMessageOriginalId]) => { useLayoutEffectWithPrevDeps(([
prevMessageIds, prevIsViewportNewest, prevCurrentLastMessageOriginalId, prevLiveTailStartOriginalId,
]) => {
if (process.env.APP_ENV === 'perf') { if (process.env.APP_ENV === 'perf') {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.time('scrollTop'); console.time('scrollTop');
@ -685,11 +744,34 @@ const MessageList = ({
messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1) messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1)
); );
const wasMessageAdded = hasLastMessageChanged && !hasViewportShifted; 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<HTMLDivElement>('.live-tail');
return liveTailElement ? getShouldReleaseLiveTail(liveTailElement) : false;
}),
);
// Add extra height when few messages to allow scroll animation // Add extra height when few messages to allow scroll animation
if ( if (
isViewportNewest isViewportNewest
&& wasMessageAdded && wasMessageAdded
&& !hasLiveTail
&& (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2) && (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2)
&& !container.parentElement!.classList.contains(FORCE_MESSAGES_SCROLL_CLASS) && !container.parentElement!.classList.contains(FORCE_MESSAGES_SCROLL_CLASS)
&& forceMeasure(() => ( && forceMeasure(() => (
@ -730,10 +812,13 @@ const MessageList = ({
bottomOffset -= lastItemHeight; bottomOffset -= lastItemHeight;
} }
const isAtBottom = isViewportNewest && prevIsViewportNewest && bottomOffset <= BOTTOM_THRESHOLD; const isAtBottom = isViewportNewest && prevIsViewportNewest && bottomOffset <= BOTTOM_THRESHOLD;
const shouldFocusLiveTail = wasLiveTailCreated && isAtBottom;
const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1]; const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1];
// Animate incoming message, but if app is in background mode, scroll to the first unread // 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` // Break out of `forceLayout`
requestMeasure(() => { requestMeasure(() => {
const isScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement; const isScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement;
@ -744,6 +829,15 @@ const MessageList = ({
margin: BOTTOM_FOCUS_MARGIN, margin: BOTTOM_FOCUS_MARGIN,
forceDuration: noMessageSendingAnimation ? 0 : undefined, 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 && memoUnreadDividerBeforeIdRef.current
&& container.querySelector<HTMLDivElement>(`.${UNREAD_DIVIDER_CLASS}`) && container.querySelector<HTMLDivElement>(`.${UNREAD_DIVIDER_CLASS}`)
); );
const liveTailElement = shouldFocusLiveTail
? container.querySelector<HTMLDivElement>('.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; 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; newScrollTop = scrollHeight - offsetHeight;
} else if (anchor) { } else if (anchor) {
const newAnchorTop = anchor.getBoundingClientRect().top; const newAnchorTop = anchor.getBoundingClientRect().top;
@ -775,6 +887,19 @@ const MessageList = ({
} }
return () => { 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)); resetScroll(container, Math.ceil(newScrollTop));
requestMeasure(() => { requestMeasure(() => {
isReplacingHistoryRef.current = false; isReplacingHistoryRef.current = false;
@ -804,6 +929,8 @@ const MessageList = ({
messageIds, messageIds,
isViewportNewest, isViewportNewest,
currentLastMessageOriginalId, currentLastMessageOriginalId,
effectiveLiveTailStartOriginalId,
isCurrentLastMessageTypingDraft,
getContainerHeight, getContainerHeight,
prevContainerHeightRef, prevContainerHeightRef,
noMessageSendingAnimation, noMessageSendingAnimation,
@ -916,6 +1043,7 @@ const MessageList = ({
anchorIdRef={anchorIdRef} anchorIdRef={anchorIdRef}
memoUnreadDividerBeforeIdRef={memoUnreadDividerBeforeIdRef} memoUnreadDividerBeforeIdRef={memoUnreadDividerBeforeIdRef}
memoFirstUnreadIdRef={memoFirstUnreadIdRef} memoFirstUnreadIdRef={memoFirstUnreadIdRef}
liveTailStartOriginalId={effectiveLiveTailStartOriginalId}
isReplacingHistoryRef={isReplacingHistoryRef} isReplacingHistoryRef={isReplacingHistoryRef}
threadId={threadId} threadId={threadId}
type={type} type={type}
@ -933,7 +1061,6 @@ const MessageList = ({
onScrollDownToggle={onScrollDownToggle} onScrollDownToggle={onScrollDownToggle}
onNotchToggle={onNotchToggle} onNotchToggle={onNotchToggle}
onIntersectPinnedMessage={onIntersectPinnedMessage} onIntersectPinnedMessage={onIntersectPinnedMessage}
onTallTypingDraft={handleTallTypingDraft}
/> />
) : ( ) : (
<Loading color="white" backgroundColor="dark" /> <Loading color="white" backgroundColor="dark" />

View File

@ -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 { getIsHeavyAnimating, memo } from '../../lib/teact/teact';
import { getActions, getGlobal } from '../../global'; import { getActions, getGlobal } from '../../global';
@ -70,6 +70,7 @@ interface OwnProps {
anchorIdRef: { current: string | undefined }; anchorIdRef: { current: string | undefined };
memoUnreadDividerBeforeIdRef: { current: number | undefined }; memoUnreadDividerBeforeIdRef: { current: number | undefined };
memoFirstUnreadIdRef: { current: number | undefined }; memoFirstUnreadIdRef: { current: number | undefined };
liveTailStartOriginalId?: number;
isReplacingHistoryRef: { current: boolean }; isReplacingHistoryRef: { current: boolean };
type: MessageListType; type: MessageListType;
isReady: boolean; isReady: boolean;
@ -86,11 +87,23 @@ interface OwnProps {
onScrollDownToggle?: BooleanToVoidFunction; onScrollDownToggle?: BooleanToVoidFunction;
onNotchToggle?: AnyToVoidFunction; onNotchToggle?: AnyToVoidFunction;
onIntersectPinnedMessage?: OnIntersectPinnedMessage; onIntersectPinnedMessage?: OnIntersectPinnedMessage;
onTallTypingDraft?: (messageId: number, isNearExit: boolean) => void;
} }
const UNREAD_DIVIDER_CLASS = 'unread-divider'; 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 = ({ const MessageListContent = ({
canShowAds, canShowAds,
chatId, chatId,
@ -111,6 +124,7 @@ const MessageListContent = ({
anchorIdRef, anchorIdRef,
memoUnreadDividerBeforeIdRef, memoUnreadDividerBeforeIdRef,
memoFirstUnreadIdRef, memoFirstUnreadIdRef,
liveTailStartOriginalId,
isReplacingHistoryRef, isReplacingHistoryRef,
type, type,
isReady, isReady,
@ -127,7 +141,6 @@ const MessageListContent = ({
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
onIntersectPinnedMessage, onIntersectPinnedMessage,
onTallTypingDraft,
}: OwnProps) => { }: OwnProps) => {
const { openHistoryCalendar } = getActions(); const { openHistoryCalendar } = getActions();
@ -159,7 +172,6 @@ const MessageListContent = ({
backwardsTriggerRef, backwardsTriggerRef,
forwardsTriggerRef, forwardsTriggerRef,
fabTriggerRef, fabTriggerRef,
observeIntersectionForTopExit,
} = useScrollHooks({ } = useScrollHooks({
type, type,
containerRef, containerRef,
@ -352,6 +364,7 @@ const MessageListContent = ({
) { ) {
const isOwn = isOwnMessage(message); const isOwn = isOwnMessage(message);
const originalId = getMessageOriginalId(message); const originalId = getMessageOriginalId(message);
const isInLiveTail = liveTailStartOriginalId !== undefined && originalId >= liveTailStartOriginalId;
const key = isServiceNotificationMessage(message) const key = isServiceNotificationMessage(message)
? `${message.date}_${originalId}` : originalId; ? `${message.date}_${originalId}` : originalId;
const shouldShowGuestAvatar = isPrivate && !withUsers && Boolean(message.guestChatViaId); const shouldShowGuestAvatar = isPrivate && !withUsers && Boolean(message.guestChatViaId);
@ -384,11 +397,11 @@ const MessageListContent = ({
isFirstInDocumentGroup={position.isFirstInDocumentGroup} isFirstInDocumentGroup={position.isFirstInDocumentGroup}
isLastInDocumentGroup={position.isLastInDocumentGroup} isLastInDocumentGroup={position.isLastInDocumentGroup}
isLastInList={position.isLastInList} isLastInList={position.isLastInList}
shouldIgnoreSendFocus={isInLiveTail && isOwn}
isQuickPreview={isQuickPreview}
memoFirstUnreadIdRef={memoFirstUnreadIdRef} memoFirstUnreadIdRef={memoFirstUnreadIdRef}
getIsMessageListReady={getIsReady} getIsMessageListReady={getIsReady}
observeIntersectionForTopExit={observeIntersectionForTopExit}
onMessageUnmount={onMessageUnmount} onMessageUnmount={onMessageUnmount}
onTallTypingDraft={onTallTypingDraft}
/>, />,
]); ]);
} }
@ -504,43 +517,97 @@ const MessageListContent = ({
</div> </div>
), ),
]); ]);
}).flat(); });
} }
const dateGroups = messageGroups.map(( function renderDateHeader(dateGroup: MessageDateGroup) {
return (
<div
className={buildClassName('sticky-date', areDatesClickable && 'interactive')}
key="date-header"
onMouseDown={preventMessageInputBlur}
onClick={areDatesClickable ? () => openHistoryCalendar({ selectedAt: dateGroup.datetime }) : undefined}
>
<span dir="auto">
{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))}
</span>
</div>
);
}
function renderDateGroup(
dateGroup: MessageDateGroup,
children: TeactNode[],
keySuffix: string,
shouldAddFirstClass: boolean,
) {
return (
<div
className={buildClassName('message-date-group', shouldAddFirstClass && 'first-message-date-group')}
key={`${dateGroup.datetime}-${keySuffix}`}
onMouseDown={preventMessageInputBlur}
teactFastList
>
{children}
</div>
);
}
let isRenderingLiveTail = false;
const dateGroups: TeactNode[] = [];
const liveTailDateGroups: TeactNode[] = [];
messageGroups.forEach((
dateGroup: MessageDateGroup, dateGroup: MessageDateGroup,
dateGroupIndex: number, dateGroupIndex: number,
dateGroupsArray: MessageDateGroup[], dateGroupsArray: MessageDateGroup[],
) => { ) => {
const senderGroups = calculateSenderGroups(dateGroup, dateGroupIndex, dateGroupsArray); const senderGroups = calculateSenderGroups(dateGroup, dateGroupIndex, dateGroupsArray);
const beforeTailChildren: TeactNode[] = [];
const liveTailChildren: TeactNode[] = [];
return ( if (isRenderingLiveTail) {
<div liveTailChildren.push(renderDateHeader(dateGroup));
className={buildClassName('message-date-group', !(nameChangeDate || photoChangeDate) } else {
&& dateGroupIndex === 0 && 'first-message-date-group')} beforeTailChildren.push(renderDateHeader(dateGroup));
key={dateGroup.datetime} }
onMouseDown={preventMessageInputBlur}
teactFastList senderGroups.forEach((senderGroupElements, senderGroupIndex) => {
> const isLiveTailStart = (
<div !isRenderingLiveTail
className={buildClassName('sticky-date', areDatesClickable && 'interactive')} && liveTailStartOriginalId !== undefined
key="date-header" && senderGroupContainsOriginalId(
onMouseDown={preventMessageInputBlur} dateGroup.senderGroups[senderGroupIndex],
onClick={areDatesClickable ? () => openHistoryCalendar({ selectedAt: dateGroup.datetime }) : undefined} liveTailStartOriginalId,
> )
<span dir="auto"> );
{isSchedule && dateGroup.originalDate === SCHEDULED_WHEN_ONLINE && (
oldLang('MessageScheduledUntilOnline') if (isLiveTailStart) {
)} isRenderingLiveTail = true;
{isSchedule && dateGroup.originalDate !== SCHEDULED_WHEN_ONLINE && ( }
oldLang('MessageScheduledOn', formatHumanDate(oldLang, dateGroup.datetime, undefined, true))
)} const target = isRenderingLiveTail ? liveTailChildren : beforeTailChildren;
{!isSchedule && formatMessageListDate(lang, new Date(dateGroup.datetime))} target.push(...senderGroupElements);
</span> });
</div>
{senderGroups.flat()} const shouldAddFirstClass = !(nameChangeDate || photoChangeDate) && dateGroupIndex === 0;
</div> 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 ( return (
@ -548,7 +615,12 @@ const MessageListContent = ({
{withHistoryTriggers && <div ref={backwardsTriggerRef} key="backwards-trigger" className="backwards-trigger" />} {withHistoryTriggers && <div ref={backwardsTriggerRef} key="backwards-trigger" className="backwards-trigger" />}
{shouldRenderAccountInfo {shouldRenderAccountInfo
&& <MessageListAccountInfo key={`account_info_${chatId}`} chatId={chatId} hasMessages />} && <MessageListAccountInfo key={`account_info_${chatId}`} chatId={chatId} hasMessages />}
{dateGroups.flat()} {dateGroups}
{Boolean(liveTailDateGroups.length) && (
<div className="live-tail" key="live-tail" teactFastList>
{liveTailDateGroups}
</div>
)}
{isViewportNewest && renderBotForumTopicAction()} {isViewportNewest && renderBotForumTopicAction()}
{withHistoryTriggers && ( {withHistoryTriggers && (
<div <div

View File

@ -1,7 +1,7 @@
import type { ApiMessage } from '../../../api/types'; import type { ApiMessage } from '../../../api/types';
import type { IAlbum, IDocumentGroup } from '../../../types'; import type { IAlbum, IDocumentGroup } from '../../../types';
import { isActionMessage } from '../../../global/helpers'; import { getMessageOriginalId, isActionMessage } from '../../../global/helpers';
import { getDayStartAt } from '../../../util/dates/oldDateFormat'; import { getDayStartAt } from '../../../util/dates/oldDateFormat';
type SenderGroup = (ApiMessage | IAlbum | IDocumentGroup)[]; type SenderGroup = (ApiMessage | IAlbum | IDocumentGroup)[];
@ -26,6 +26,7 @@ export function isDocumentGroup(
export function groupMessages( export function groupMessages(
messages: ApiMessage[], firstUnreadId?: number, topMessageId?: number, isChatWithSelf?: boolean, withUsers?: boolean, messages: ApiMessage[], firstUnreadId?: number, topMessageId?: number, isChatWithSelf?: boolean, withUsers?: boolean,
splitBeforeMessageId?: number,
) { ) {
const initDateGroup: MessageDateGroup = { const initDateGroup: MessageDateGroup = {
originalDate: messages[0].date, originalDate: messages[0].date,
@ -120,6 +121,7 @@ export function groupMessages(
dateGroups.push(newDateGroup); dateGroups.push(newDateGroup);
} else if ( } else if (
nextMessage.id === firstUnreadId nextMessage.id === firstUnreadId
|| (splitBeforeMessageId !== undefined && getMessageOriginalId(nextMessage) === splitBeforeMessageId)
|| message.senderId !== nextMessage.senderId || message.senderId !== nextMessage.senderId
|| message.guestChatViaId !== nextMessage.guestChatViaId || message.guestChatViaId !== nextMessage.guestChatViaId
|| (!withUsers && message.paidMessageStars) || (!withUsers && message.paidMessageStars)

View File

@ -77,6 +77,10 @@ export default function useMessageObservers({
const shouldUpdateViews = dataset.shouldUpdateViews === 'true'; const shouldUpdateViews = dataset.shouldUpdateViews === 'true';
const albumMainId = dataset.albumMainId ? Number(dataset.albumMainId) : undefined; const albumMainId = dataset.albumMainId ? Number(dataset.albumMainId) : undefined;
if (!Number.isInteger(messageId)) {
return;
}
if (!isIntersecting) { if (!isIntersecting) {
hiddenViewportIds.add(messageId); hiddenViewportIds.add(messageId);
return; return;

View File

@ -19,7 +19,6 @@ import useSyncEffect from '../../../hooks/useSyncEffect';
const FAB_THRESHOLD = 50; const FAB_THRESHOLD = 50;
const NOTCH_THRESHOLD = 1; // Notch has zero height so we at least need a 1px margin to intersect const NOTCH_THRESHOLD = 1; // Notch has zero height so we at least need a 1px margin to intersect
const TOP_EXIT_THRESHOLD = 50;
const CONTAINER_HEIGHT_DEBOUNCE = 200; const CONTAINER_HEIGHT_DEBOUNCE = 200;
const SCROLL_TOOLS_DEBOUNCE = 100; const SCROLL_TOOLS_DEBOUNCE = 100;
const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration
@ -157,14 +156,6 @@ export default function useScrollHooks({
useOnIntersect(fabTriggerRef, observeIntersectionForNotch); useOnIntersect(fabTriggerRef, observeIntersectionForNotch);
const {
observe: observeIntersectionForTopExit,
} = useIntersectionObserver({
rootRef: containerRef,
margin: `-${TOP_EXIT_THRESHOLD}px 0px 0px 0px`,
throttleScheduler: requestMeasure,
});
useEffect(() => { useEffect(() => {
if (isReady) { if (isReady) {
updateScrollTools(); updateScrollTools();
@ -204,6 +195,5 @@ export default function useScrollHooks({
backwardsTriggerRef, backwardsTriggerRef,
forwardsTriggerRef, forwardsTriggerRef,
fabTriggerRef, fabTriggerRef,
observeIntersectionForTopExit,
}; };
} }

View File

@ -809,17 +809,6 @@
visibility: hidden; visibility: hidden;
} }
.top-marker {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
visibility: hidden;
}
.giveaway-result-content { .giveaway-result-content {
min-width: 17rem; min-width: 17rem;
} }

View File

@ -239,14 +239,14 @@ type OwnProps = {
appearanceOrder: number; appearanceOrder: number;
isJustAdded: boolean; isJustAdded: boolean;
isThreadTop?: boolean; isThreadTop?: boolean;
shouldIgnoreSendFocus?: boolean;
isQuickPreview?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined }; memoFirstUnreadIdRef?: { current: number | undefined };
getIsMessageListReady?: Signal<boolean>; getIsMessageListReady?: Signal<boolean>;
observeIntersectionForBottom?: ObserveFn; observeIntersectionForBottom?: ObserveFn;
observeIntersectionForLoading?: ObserveFn; observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn; observeIntersectionForPlaying?: ObserveFn;
observeIntersectionForTopExit?: ObserveFn;
onMessageUnmount?: (messageId: number) => void; onMessageUnmount?: (messageId: number) => void;
onTallTypingDraft?: (messageId: number, isNearExit: boolean) => void;
} & MessagePositionProperties; } & MessagePositionProperties;
type StateProps = { type StateProps = {
@ -477,9 +477,8 @@ const Message = ({
observeIntersectionForBottom, observeIntersectionForBottom,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
observeIntersectionForTopExit, isQuickPreview,
onMessageUnmount, onMessageUnmount,
onTallTypingDraft,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
toggleMessageSelection, toggleMessageSelection,
@ -491,6 +490,7 @@ const Message = ({
animateUnreadReaction, animateUnreadReaction,
focusMessage, focusMessage,
markTypingDraftDone, markTypingDraftDone,
markMessageListRead,
markMentionsRead, markMentionsRead,
markPollVotesRead, markPollVotesRead,
openThread, openThread,
@ -498,7 +498,6 @@ const Message = ({
} = getActions(); } = getActions();
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
const topMarkerRef = useRef<HTMLDivElement>();
const bottomMarkerRef = useRef<HTMLDivElement>(); const bottomMarkerRef = useRef<HTMLDivElement>();
const quickReactionRef = useRef<HTMLDivElement>(); const quickReactionRef = useRef<HTMLDivElement>();
@ -519,16 +518,7 @@ const Message = ({
const [declineReason, setDeclineReason] = useState(''); const [declineReason, setDeclineReason] = useState('');
const { isMobile, isTouchScreen } = useAppLayout(); const { isMobile, isTouchScreen } = useAppLayout();
useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); useOnIntersect(bottomMarkerRef, isTypingDraft ? undefined : observeIntersectionForBottom);
const handleTypingDraftNearExit = useLastCallback(({ isIntersecting }: IntersectionObserverEntry) => {
onTallTypingDraft?.(messageId, !isIntersecting);
});
useOnIntersect(
topMarkerRef,
isTypingDraft && isLastInList ? observeIntersectionForTopExit : undefined,
handleTypingDraftNearExit,
);
const { const {
isContextMenuOpen, isContextMenuOpen,
@ -993,9 +983,23 @@ const Message = ({
|| undefined; || undefined;
useEffect(() => { useEffect(() => {
if (isTypingDraft) {
return;
}
const bottomMarker = bottomMarkerRef.current; const bottomMarker = bottomMarkerRef.current;
if (!bottomMarker || !isElementInViewport(bottomMarker)) return; if (!bottomMarker || !isElementInViewport(bottomMarker)) return;
if (
message.wasTypingDraft
&& !isQuickPreview
&& !isOwn
&& memoFirstUnreadIdRef?.current
&& messageId >= memoFirstUnreadIdRef.current
) {
markMessageListRead({ maxId: messageId });
}
if (hasUnreadReaction) { if (hasUnreadReaction) {
animateUnreadReaction({ chatId, messageIds: [messageId] }); animateUnreadReaction({ chatId, messageIds: [messageId] });
} }
@ -1021,10 +1025,16 @@ const Message = ({
hasUnreadPollVote, hasUnreadPollVote,
album, album,
chatId, chatId,
isQuickPreview,
isOwn,
isTypingDraft,
markMessageListRead,
messageId, messageId,
memoFirstUnreadIdRef,
animateUnreadReaction, animateUnreadReaction,
markPollVotesRead, markPollVotesRead,
message.hasUnreadMention, message.hasUnreadMention,
message.wasTypingDraft,
]); ]);
const albumLayout = useMemo(() => { const albumLayout = useMemo(() => {
@ -1914,10 +1924,6 @@ const Message = ({
onMouseMove={withQuickReactionButton ? handleMouseMove : undefined} onMouseMove={withQuickReactionButton ? handleMouseMove : undefined}
onMouseLeave={(withQuickReactionButton || isInDocumentGroupNotLast) ? handleMouseLeave : undefined} onMouseLeave={(withQuickReactionButton || isInDocumentGroupNotLast) ? handleMouseLeave : undefined}
> >
<div
ref={topMarkerRef}
className="top-marker"
/>
<div <div
ref={bottomMarkerRef} ref={bottomMarkerRef}
className="bottom-marker" className="bottom-marker"
@ -2100,7 +2106,7 @@ export default memo(withGlobal<OwnProps>(
} = selectTabState(global); } = selectTabState(global);
const { const {
message, album, documentGroup, withSenderName, withAvatar, threadId, messageListType, message, album, documentGroup, withSenderName, withAvatar, threadId, messageListType,
isLastInDocumentGroup, isFirstInGroup, isLastInDocumentGroup, isFirstInGroup, shouldIgnoreSendFocus,
} = ownProps; } = ownProps;
const { const {
id, chatId, viaBotId, guestChatViaId, isOutgoing, forwardInfo, transcriptionId, isPinned, id, chatId, viaBotId, guestChatViaId, isOutgoing, forwardInfo, transcriptionId, isPinned,
@ -2158,11 +2164,18 @@ export default memo(withGlobal<OwnProps>(
const storySender = storyReplyPeerId ? selectPeer(global, storyReplyPeerId) : undefined; const storySender = storyReplyPeerId ? selectPeer(global, storyReplyPeerId) : undefined;
const uploadProgress = selectUploadProgress(global, message); const uploadProgress = selectUploadProgress(global, message);
const isFocused = messageListType === 'thread' && ( const isFocusTarget = messageListType === 'thread' && (
album album
? album.messages.some((m) => selectIsMessageFocused(global, m, threadId)) ? album.messages.some((m) => selectIsMessageFocused(global, m, threadId))
: selectIsMessageFocused(global, message, threadId) : selectIsMessageFocused(global, message, threadId)
); );
const shouldIgnoreFocus = Boolean(
isFocusTarget
&& shouldIgnoreSendFocus
&& focusedMessage?.noHighlight
&& focusedMessage.isResizingContainer,
);
const isFocused = isFocusTarget && !shouldIgnoreFocus;
const { const {
direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer, direction: focusDirection, noHighlight: noFocusHighlight, isResizingContainer,

View File

@ -1287,6 +1287,7 @@ addActionHandler('reportChannelSpam', (global, actions, payload): ActionReturnTy
addActionHandler('markMessageListRead', (global, actions, payload): ActionReturnType => { addActionHandler('markMessageListRead', (global, actions, payload): ActionReturnType => {
if (selectIsCurrentUserFrozen(global)) return undefined; if (selectIsCurrentUserFrozen(global)) return undefined;
const { maxId, tabId = getCurrentTabId() } = payload; const { maxId, tabId = getCurrentTabId() } = payload;
if (isLocalMessageId(maxId)) return undefined;
const currentMessageList = selectCurrentMessageList(global, tabId); const currentMessageList = selectCurrentMessageList(global, tabId);
if (!currentMessageList) { if (!currentMessageList) {

View File

@ -213,6 +213,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
...message, ...message,
previousLocalId: matchedTypingDraftEntry.message.id, previousLocalId: matchedTypingDraftEntry.message.id,
isTypingDraft: true, isTypingDraft: true,
wasTypingDraft: true,
} : message; } : message;
global = updateWithLocalMedia(global, chatId, id, true, nextMessage); global = updateWithLocalMedia(global, chatId, id, true, nextMessage);

View File

@ -862,6 +862,7 @@ export function selectFirstUnreadId<T extends GlobalState>(
return ( return (
(!lastReadId || id > lastReadId) (!lastReadId || id > lastReadId)
&& byId[id] && byId[id]
&& !byId[id].isTypingDraft
&& (!byId[id].isOutgoing || byId[id].isFromScheduled) && (!byId[id].isOutgoing || byId[id].isFromScheduled)
&& id > lastReadServiceNotificationId && id > lastReadServiceNotificationId
); );