Message List: Move streaming responses to the top (#6924)
This commit is contained in:
parent
a103f09dae
commit
2b6fca15bc
@ -800,6 +800,7 @@ export interface ApiMessage {
|
||||
fromRank?: string;
|
||||
|
||||
isTypingDraft?: boolean; // Local field
|
||||
wasTypingDraft?: boolean; // Local field
|
||||
}
|
||||
|
||||
export interface ApiReactions {
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user