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;
isTypingDraft?: boolean; // Local field
wasTypingDraft?: boolean; // Local field
}
export interface ApiReactions {

View File

@ -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); }
}

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 { 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<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 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<HTMLDivElement>('.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<HTMLDivElement>) => {
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<HTMLDivElement>('.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<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;
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}
/>
) : (
<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 { 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 = ({
</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,
dateGroupIndex: number,
dateGroupsArray: MessageDateGroup[],
) => {
const senderGroups = calculateSenderGroups(dateGroup, dateGroupIndex, dateGroupsArray);
const beforeTailChildren: TeactNode[] = [];
const liveTailChildren: TeactNode[] = [];
return (
<div
className={buildClassName('message-date-group', !(nameChangeDate || photoChangeDate)
&& dateGroupIndex === 0 && 'first-message-date-group')}
key={dateGroup.datetime}
onMouseDown={preventMessageInputBlur}
teactFastList
>
<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>
{senderGroups.flat()}
</div>
);
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 && <div ref={backwardsTriggerRef} key="backwards-trigger" className="backwards-trigger" />}
{shouldRenderAccountInfo
&& <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()}
{withHistoryTriggers && (
<div

View File

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

View File

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

View File

@ -19,7 +19,6 @@ import useSyncEffect from '../../../hooks/useSyncEffect';
const FAB_THRESHOLD = 50;
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 SCROLL_TOOLS_DEBOUNCE = 100;
const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration
@ -157,14 +156,6 @@ export default function useScrollHooks({
useOnIntersect(fabTriggerRef, observeIntersectionForNotch);
const {
observe: observeIntersectionForTopExit,
} = useIntersectionObserver({
rootRef: containerRef,
margin: `-${TOP_EXIT_THRESHOLD}px 0px 0px 0px`,
throttleScheduler: requestMeasure,
});
useEffect(() => {
if (isReady) {
updateScrollTools();
@ -204,6 +195,5 @@ export default function useScrollHooks({
backwardsTriggerRef,
forwardsTriggerRef,
fabTriggerRef,
observeIntersectionForTopExit,
};
}

View File

@ -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;
}

View File

@ -239,14 +239,14 @@ type OwnProps = {
appearanceOrder: number;
isJustAdded: boolean;
isThreadTop?: boolean;
shouldIgnoreSendFocus?: boolean;
isQuickPreview?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined };
getIsMessageListReady?: Signal<boolean>;
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<HTMLDivElement>();
const topMarkerRef = useRef<HTMLDivElement>();
const bottomMarkerRef = useRef<HTMLDivElement>();
const quickReactionRef = useRef<HTMLDivElement>();
@ -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}
>
<div
ref={topMarkerRef}
className="top-marker"
/>
<div
ref={bottomMarkerRef}
className="bottom-marker"
@ -2100,7 +2106,7 @@ export default memo(withGlobal<OwnProps>(
} = 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<OwnProps>(
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,

View File

@ -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) {

View File

@ -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);

View File

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