From 9d3d8bdd57b363016c13287edcacc09b04490a63 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 15:14:01 +0300 Subject: [PATCH] Fix chat history return button behavior --- frontend/src/app/messages/[chatId]/page.tsx | 274 ++++++++++++++++-- .../src/components/ScrollableMessageList.tsx | 66 +++-- 2 files changed, 301 insertions(+), 39 deletions(-) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index fb5739e6..bfb8f767 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; import { useParams } from "next/navigation"; -import { MessageCircle, ChevronDown } from "lucide-react"; +import { MessageCircle, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; @@ -66,6 +66,24 @@ function getMessageDayKey(message: Message): string | null { return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; } +function formatDateInputValue(date: Date): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +function shiftDateInputValue(value: string, deltaDays: number): string { + const baseDate = value ? new Date(`${value}T00:00:00`) : new Date(); + if (Number.isNaN(baseDate.getTime())) { + return formatDateInputValue(new Date()); + } + + baseDate.setDate(baseDate.getDate() + deltaDays); + return formatDateInputValue(baseDate); +} + export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); const chatId = Number(params.chatId); @@ -93,7 +111,12 @@ export default function MessageDialogPage() { const [useOriginalImage, setUseOriginalImage] = useState>({}); const [mediaPreview, setMediaPreview] = useState(null); const [floatingDate, setFloatingDate] = useState(null); + const [floatingDateValue, setFloatingDateValue] = useState(null); const [showFloatingDate, setShowFloatingDate] = useState(false); + const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false); + const [selectedHistoryDate, setSelectedHistoryDate] = useState(""); + const [jumpingToDate, setJumpingToDate] = useState(false); + const [historyNavigationMode, setHistoryNavigationMode] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [newMessagesBelowCount, setNewMessagesBelowCount] = useState(0); const floatingDateTimeoutRef = useRef(null); @@ -148,6 +171,12 @@ export default function MessageDialogPage() { pendingMessagesRef.current = pendingMessages; }, [pendingMessages]); + useEffect(() => { + if (floatingDateValue && !isDateNavigatorOpen) { + setSelectedHistoryDate(floatingDateValue); + } + }, [floatingDateValue, isDateNavigatorOpen]); + useEffect(() => { lastConfirmedMarkReadIdRef.current = Math.max( lastConfirmedMarkReadIdRef.current, @@ -166,6 +195,7 @@ export default function MessageDialogPage() { lastConfirmedMarkReadIdRef.current = chat?.last_read_message_id ?? 0; unreadBelowMessageIdsRef.current.clear(); setNewMessagesBelowCount(0); + setHistoryNavigationMode(false); }, [chatId, chat?.last_read_message_id]); // Определяем текущий membership и права на отправку сообщений @@ -495,14 +525,19 @@ export default function MessageDialogPage() { const syncScrollToBottomState = useCallback(() => { const isAtBottom = isNearBottom(); - setShowScrollToBottom(!isAtBottom); + const hasBufferedNewer = hasMoreNewerRef.current || unreadBelowMessageIdsRef.current.size > 0; - if (isAtBottom) { + if (isAtBottom && !hasMoreNewerRef.current) { + setShowScrollToBottom(false); resetNewMessagesBelow(); + setHistoryNavigationMode(false); + return true; } + setShowScrollToBottom(!isAtBottom || hasBufferedNewer || historyNavigationMode); + return isAtBottom; - }, [isNearBottom, resetNewMessagesBelow]); + }, [historyNavigationMode, isNearBottom, resetNewMessagesBelow]); // Автопрокрутка вниз const scrollToBottom = useCallback((smooth = false) => { @@ -1094,7 +1129,7 @@ export default function MessageDialogPage() { } const newestMessage = messages[messages.length - 1]; - const shouldMarkRead = isNearBottom(); + const shouldMarkRead = !historyNavigationMode && isNearBottom(); try { setLoadingNewer(true); @@ -1126,7 +1161,7 @@ export default function MessageDialogPage() { } finally { setLoadingNewer(false); } - }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead]); + }, [chatId, hasMoreNewer, historyNavigationMode, isNearBottom, loadingNewer, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead]); useChatFallbackSync({ chatId: chatId || null, @@ -1151,15 +1186,16 @@ export default function MessageDialogPage() { loadOlderMessages(); } - // Загрузка новых сообщений при приближении к низу - const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - if (!loadingNewer && !messagesLoading && hasMoreNewer && distanceFromBottom <= 120) { + // Загрузка новых сообщений при приближении к низу (щедрый порог для предзагрузки) + const distFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (!loadingNewer && !messagesLoading && hasMoreNewer && distFromBottom <= 300) { loadNewerMessages(); } const messageEls = viewport.querySelectorAll('[data-message-date]'); const viewportTop = viewport.getBoundingClientRect().top; let topDate: string | null = null; + let topDateValue: string | null = null; for (let i = 0; i < messageEls.length; i += 1) { const el = messageEls[i]; @@ -1171,6 +1207,7 @@ export default function MessageDialogPage() { const date = new Date(isoDate); if (!Number.isNaN(date.getTime())) { topDate = formatDayDivider(date); + topDateValue = formatDateInputValue(date); } } break; @@ -1179,6 +1216,7 @@ export default function MessageDialogPage() { if (topDate) { setFloatingDate(topDate); + setFloatingDateValue(topDateValue); setShowFloatingDate(true); if (floatingDateTimeoutRef.current) { @@ -1409,6 +1447,135 @@ export default function MessageDialogPage() { setUseOriginalImage((prev) => ({ ...prev, [attachmentId]: true })); }, []); + const openDateNavigator = useCallback((dateValue?: string | null) => { + setSelectedHistoryDate(dateValue || floatingDateValue || formatDateInputValue(new Date())); + setShowFloatingDate(true); + setIsDateNavigatorOpen(true); + }, [floatingDateValue]); + + const handleJumpToDate = useCallback(async (dateValue: string) => { + if (!chatId || Number.isNaN(chatId) || !dateValue || jumpingToDate) { + return; + } + + const dayStart = new Date(`${dateValue}T00:00:00`); + if (Number.isNaN(dayStart.getTime())) { + return; + } + + try { + setJumpingToDate(true); + setHistoryNavigationMode(true); + setMessagesLoading(true); + setIsDateNavigatorOpen(false); + + const [olderResponse, newerResponse] = await Promise.all([ + apiClient.getChatMessages(chatId, { + limit: 20, + before: dayStart.toISOString(), + }), + apiClient.getChatMessages(chatId, { + limit: 20, + after: dayStart.toISOString(), + mark_read: false, + }), + ]); + + const olderMessages = olderResponse.messages || []; + const newerMessages = newerResponse.messages || []; + const combinedMessages = uniqueMessagesById([...olderMessages, ...newerMessages]); + const anchorMessage = newerMessages[0] || olderMessages[olderMessages.length - 1] || combinedMessages[0] || null; + + if (!anchorMessage) { + return; + } + + setMessages(combinedMessages); + setHasMoreOlder(Boolean(olderResponse.has_more)); + setHasMoreNewer(Boolean(newerResponse.has_more)); + setInitialAnchorId(anchorMessage.id); + setInitialAnchorIndex(combinedMessages.findIndex((message) => message.id === anchorMessage.id)); + setAllowOneOlderProbe(Boolean(newerMessages[0])); + + const anchorDate = getMessageDate(anchorMessage); + if (anchorDate) { + setFloatingDate(formatDayDivider(anchorDate)); + setFloatingDateValue(formatDateInputValue(anchorDate)); + setShowFloatingDate(true); + } + + initialScrolledRef.current = false; + anchorScrollAttemptsRef.current = 0; + anchorOlderLoadAttemptsRef.current = 0; + setAnchorRetryTick(0); + resetNewMessagesBelow(); + } catch (jumpError) { + console.error("Ошибка перехода к дате:", jumpError); + } finally { + setMessagesLoading(false); + setJumpingToDate(false); + } + }, [chatId, jumpingToDate, resetNewMessagesBelow]); + + const handleReturnToNewMessages = useCallback(async () => { + if (!chatId || Number.isNaN(chatId)) { + return; + } + + const component = messagesViewportRef.current; + const container = component?.containerRef?.current; + const lastReadMessageId = Math.max(chat?.last_read_message_id ?? 0, lastConfirmedMarkReadIdRef.current); + const shouldRestoreUnreadBoundary = (hasMoreNewer || historyNavigationMode) && lastReadMessageId > 0; + + if (!shouldRestoreUnreadBoundary) { + setHistoryNavigationMode(false); + if (container) { + container.scrollTop = container.scrollHeight; + } + resetNewMessagesBelow(); + return; + } + + try { + setMessagesLoading(true); + + const around = await apiClient.getChatMessagesAround(chatId, { + limit: 30, + around_id: lastReadMessageId, + }); + + const normalized = uniqueMessagesById(around.messages || []); + if (normalized.length === 0) { + if (container) { + container.scrollTop = container.scrollHeight; + } + setHistoryNavigationMode(false); + resetNewMessagesBelow(); + return; + } + + setMessages(normalized); + setHasMoreOlder( + typeof around.has_more_before === "boolean" ? around.has_more_before : normalized.length >= 50 + ); + setHasMoreNewer(Boolean(around.has_more_after)); + setInitialAnchorId(around.anchor_id ?? lastReadMessageId); + setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); + setAllowOneOlderProbe(Boolean(around.anchor_id ?? lastReadMessageId)); + setHistoryNavigationMode(Boolean(around.has_more_after)); + setShowScrollToBottom(true); + + initialScrolledRef.current = false; + anchorScrollAttemptsRef.current = 0; + anchorOlderLoadAttemptsRef.current = 0; + setAnchorRetryTick(0); + } catch (error) { + console.error("Ошибка возврата к непрочитанным сообщениям:", error); + } finally { + setMessagesLoading(false); + } + }, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode, resetNewMessagesBelow]); + const handleTogglePin = async () => { if (!chatId) return; @@ -1452,11 +1619,14 @@ export default function MessageDialogPage() { if (target.closest("[data-reaction-picker='true']")) return; if (target.closest(".reaction-picker-modal")) return; if (target.closest("[data-composer-emoji='true']")) return; + if (target.closest("[data-date-navigator='true']")) return; + if (target.closest("[data-date-trigger='true']")) return; setExpandedReplyActionForId(null); setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); + setIsDateNavigatorOpen(false); }; window.addEventListener("mousedown", onWindowClick); @@ -1474,6 +1644,7 @@ export default function MessageDialogPage() { setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); + setIsDateNavigatorOpen(false); } }; window.addEventListener("keydown", onKey); @@ -1551,17 +1722,76 @@ export default function MessageDialogPage() {
{floatingDate ? ( -
- +
+ +
+ ) : null} + + {isDateNavigatorOpen ? ( +
+

Перейти к дате

+
+ + setSelectedHistoryDate(event.target.value)} + className="h-9 flex-1 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-800 outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-100" + /> + +
+
+ + + +
) : null} @@ -1586,9 +1816,14 @@ export default function MessageDialogPage() { {showDayDivider && currentMessageDate ? (
- +
) : null} { - const container = messagesViewportRef.current?.containerRef?.current; - if (container) { - container.scrollTop = container.scrollHeight; - } - - resetNewMessagesBelow(); + void handleReturnToNewMessages(); }} className="relative inline-flex h-10 w-10 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm shadow-sky-200/70 transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100" title="Вернуться к новым сообщениям" diff --git a/frontend/src/components/ScrollableMessageList.tsx b/frontend/src/components/ScrollableMessageList.tsx index 88249edf..fde3c025 100644 --- a/frontend/src/components/ScrollableMessageList.tsx +++ b/frontend/src/components/ScrollableMessageList.tsx @@ -27,6 +27,8 @@ interface Snapshot { clientHeight: number; scrollHeight: number; diff: number; + anchorMessageId: string | null; + anchorOffsetTop: number | null; } export class ScrollableMessageListInner extends Component { @@ -47,6 +49,29 @@ export class ScrollableMessageListInner extends Component('[data-message-id]'); + + for (const element of messageElements) { + const messageId = element.dataset.messageId; + if (!messageId) { + continue; + } + + const rect = element.getBoundingClientRect(); + if (rect.bottom > viewportTop) { + return { + messageId, + offsetTop: rect.top - containerRect.top, + }; + } + } + + return null; + } + componentDidMount() { // Начальный скролл вниз при монтировании if (this.props.autoScrollToBottomOnMount) { @@ -115,11 +140,15 @@ export class ScrollableMessageListInner extends Component= snapshot.scrollHeight - 5 && container.scrollHeight > snapshot.scrollHeight) { - // Прокручиваем ровно на высоту добавленного контента - // Чтобы первое "старое" сообщение осталось видимым в той же позиции - const addedHeight = container.scrollHeight - snapshot.scrollHeight; - container.scrollTop = addedHeight; - } else { - // Стандартное восстановление позиции для всех остальных случаев - // Сохраняет расстояние от текущей позиции до конца списка - container.scrollTop = - container.scrollHeight - - snapshot.diff + - (this.lastClientHeight - container.clientHeight); + + if (snapshot.anchorMessageId && snapshot.anchorOffsetTop !== null) { + const anchorElement = container.querySelector(`[data-message-id="${snapshot.anchorMessageId}"]`); + + if (anchorElement) { + const containerRect = container.getBoundingClientRect(); + const currentOffsetTop = anchorElement.getBoundingClientRect().top - containerRect.top; + const offsetDiff = currentOffsetTop - snapshot.anchorOffsetTop; + + if (Math.abs(offsetDiff) > 0.5) { + container.scrollTop += offsetDiff; + } + } } } } @@ -191,7 +217,7 @@ export class ScrollableMessageListInner extends Component { + handleContentMutation = (mutations: MutationRecord[] = []) => { const container = this.containerRef.current; if (!container) return; @@ -200,6 +226,12 @@ export class ScrollableMessageListInner extends Component mutation.type === 'childList'); + + if (hasChildListMutation) { + this.lastScrollHeight = currentScrollHeight; + return; + } // ВАЖНО: Не корректируем если scrollTop близко к 0 (это prepend operation) // В этом случае коррекцию делает componentDidUpdate с правильной логикой