Прокрутка дерганая
This commit is contained in:
@@ -117,6 +117,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
||||
const lastObservedMessageIdRef = useRef<string | null>(null);
|
||||
const initialScrollChatId = useRef<string | null>(null);
|
||||
const chatScrollPositionsRef = useRef<Record<string, number>>({});
|
||||
const visitedChatsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Load muted state
|
||||
useEffect(() => {
|
||||
@@ -177,75 +179,234 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
// Прокрутка вниз
|
||||
const scrollToBottom = useCallback((smooth = true) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
||||
} else if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки)
|
||||
useLayoutEffect(() => {
|
||||
setScrollReady(false);
|
||||
}, [activeChat]);
|
||||
const isInitializingRef = useRef(false);
|
||||
const isScrollingToBottomRef = useRef(false);
|
||||
const scrollTimeoutRef = useRef<any>(null);
|
||||
const prevChatIdRef = useRef<string | null>(activeChat);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isLoadingMessages && messagesContainerRef.current && activeChat !== initialScrollChatId.current) {
|
||||
initialScrollChatId.current = activeChat;
|
||||
const container = messagesContainerRef.current;
|
||||
const unreadId = sessionUnreadRef.current.msgId;
|
||||
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (СТРОГО ПО ID)
|
||||
const saveScrollPosition = useCallback((targetChatId?: string) => {
|
||||
const container = messagesContainerRef.current;
|
||||
const chatId = targetChatId || activeChat;
|
||||
|
||||
// НЕ сохраняем, если чат ещё не восстановил свою позицию или в процессе загрузки
|
||||
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||||
|
||||
if (unreadId && activeChat === sessionUnreadRef.current.chatId) {
|
||||
const dividerEl = document.getElementById('unread-divider');
|
||||
const unreadEl = document.getElementById(`msg-${unreadId}`);
|
||||
const targetEl = dividerEl || unreadEl;
|
||||
|
||||
if (targetEl) {
|
||||
// Вычитаем отступ сверху, чтобы начало непрочитанных было под шапкой
|
||||
container.scrollTop = targetEl.offsetTop - 60;
|
||||
} else {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
}, 100);
|
||||
// ГАРАНТИЯ: Если сообщения в стейте НЕ от этого чата - не пишем в память мусор
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
|
||||
|
||||
// КРИТИЧНО: Если этот чат уже помечен как находящийся внизу,
|
||||
// не позволяем автоматике перезаписать это якорем (защита "второго клика")
|
||||
if (localStorage.getItem(`chat_at_bottom_${chatId}`) === 'true') {
|
||||
const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
|
||||
if (isNearBottomNow) {
|
||||
localStorage.removeItem(`chat_anchor_${chatId}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
setScrollReady(true);
|
||||
}
|
||||
}, [activeChat, isLoadingMessages]);
|
||||
|
||||
// Scroll on new message arrivals
|
||||
useEffect(() => {
|
||||
if (chatMessages.length > 0) {
|
||||
const lastMsg = chatMessages[chatMessages.length - 1];
|
||||
const prevId = lastObservedMessageIdRef.current;
|
||||
lastObservedMessageIdRef.current = lastMsg.id;
|
||||
const messageElements = container.querySelectorAll('[data-message-id]');
|
||||
if (messageElements.length === 0) return;
|
||||
|
||||
// Scroll ONLY if a genuinely new message was added (not during initial chat load)
|
||||
if (prevId && prevId !== lastMsg.id) {
|
||||
if (lastMsg.senderId === user?.id) {
|
||||
setTimeout(() => scrollToBottom(true), 50);
|
||||
} else {
|
||||
// Если пользователь внизу — прокрутить
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
const isNearBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
if (isNearBottom) setTimeout(() => scrollToBottom(true), 50);
|
||||
}
|
||||
}
|
||||
let anchor = null;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
for (const el of messageElements) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top >= containerRect.top) {
|
||||
anchor = {
|
||||
id: el.getAttribute('data-message-id'),
|
||||
offset: rect.top - containerRect.top
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (anchor && anchor.id) {
|
||||
localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor));
|
||||
}
|
||||
|
||||
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
|
||||
if (isAtBottomNow) {
|
||||
localStorage.setItem(`chat_at_bottom_${chatId}`, 'true');
|
||||
localStorage.removeItem(`chat_anchor_${chatId}`);
|
||||
} else {
|
||||
lastObservedMessageIdRef.current = null;
|
||||
localStorage.removeItem(`chat_at_bottom_${chatId}`);
|
||||
}
|
||||
}, [chatMessages.length, user?.id, scrollToBottom]);
|
||||
}, [activeChat, scrollReady, chatMessages]);
|
||||
|
||||
// 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container || !activeChat) return false;
|
||||
|
||||
// КРИТИЧНО: Ждем, пока в хранилище сообщений появятся данные именно от активного чата
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chatMessages.length === 0) return true;
|
||||
|
||||
// Сначала проверяем, был ли пользователь внизу (защита "второго клика")
|
||||
if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') {
|
||||
scrollToBottom(false);
|
||||
|
||||
// Агрессивные повторы, если контент еще догружается (картинки и т.д.)
|
||||
for (const delay of [100, 300, 600, 1000]) {
|
||||
setTimeout(() => {
|
||||
if (activeChat === prevChatIdRef.current) scrollToBottom(false);
|
||||
}, delay);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Восстановление по якорю сообщения
|
||||
const saved = localStorage.getItem(`chat_anchor_${activeChat}`);
|
||||
if (saved) {
|
||||
try {
|
||||
const { id, offset } = JSON.parse(saved);
|
||||
let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement;
|
||||
if (!el) {
|
||||
const msgIndex = chatMessages.findIndex(m => m.id === id);
|
||||
if (msgIndex !== -1) {
|
||||
for (let i = msgIndex; i < chatMessages.length; i++) {
|
||||
const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement;
|
||||
if (nextEl) { el = nextEl; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (el) {
|
||||
container.scrollTop = el.offsetTop - offset;
|
||||
return true;
|
||||
}
|
||||
if (chatMessages.some(m => m.id === id)) return false;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
// Если ничего нет - к непрочитанным или вниз
|
||||
const firstUnread = chatMessages.find(m => m.senderId !== user?.id && !m.readBy?.some(r => r.userId === user?.id));
|
||||
if (firstUnread) {
|
||||
const el = document.getElementById(`msg-${firstUnread.id}`) || document.getElementById('unread-divider');
|
||||
if (el) {
|
||||
container.scrollTop = (el as HTMLElement).offsetTop - 80;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
scrollToBottom(false);
|
||||
return true;
|
||||
}, [activeChat, chatMessages, scrollToBottom, user?.id]);
|
||||
|
||||
// 3. ОБЗЕРВЕР И ИНИЦИАЛИЗАЦИЯ
|
||||
useEffect(() => {
|
||||
if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return;
|
||||
const container = messagesContainerRef.current;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
// Игнорируем замеры, пока мы не отпозиционировали чат изначально
|
||||
if (isInitializingRef.current) return;
|
||||
|
||||
if (!scrollReady) {
|
||||
if (restoreScrollPosition()) {
|
||||
setScrollReady(true);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
} else if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') {
|
||||
const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
if (isNearBottomNow) {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const messagesDiv = container.querySelector('.space-y-1');
|
||||
if (messagesDiv) observer.observe(messagesDiv);
|
||||
|
||||
// Принудительно пробуем восстановить, если сообщения ПРАВИЛЬНЫЕ
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId === activeChat && !scrollReady) {
|
||||
if (restoreScrollPosition()) {
|
||||
setScrollReady(true);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [activeChat, isLoadingMessages, chatMessages, restoreScrollPosition, scrollReady, scrollToBottom]);
|
||||
|
||||
// 4. ПЕРЕКЛЮЧЕНИЕ ЧАТОВ (ФИКС RACE CONDITION)
|
||||
useLayoutEffect(() => {
|
||||
if (activeChat !== prevChatIdRef.current) {
|
||||
// СРАЗУ блокируем сохранение, чтобы handleScroll ничего не записал при изменении высоты
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// Сохраняем позицию ПРЕДЫДУЩЕГО чата ПЕРЕД установкой новых данных
|
||||
if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) {
|
||||
saveScrollPosition(prevChatIdRef.current);
|
||||
}
|
||||
|
||||
setScrollReady(false);
|
||||
prevChatIdRef.current = activeChat;
|
||||
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Таймер защиты на случай медленного рендера
|
||||
const timer = setTimeout(() => {
|
||||
isInitializingRef.current = false;
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [activeChat, saveScrollPosition, scrollReady]);
|
||||
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container || isInitializingRef.current) return;
|
||||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
setShowScrollDown(!isNearBottom);
|
||||
}, []);
|
||||
|
||||
const handleScroll = () => {
|
||||
// В период инициализации (1.2с) или принудительного скролла вниз - игнорируем любые события.
|
||||
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||||
|
||||
checkScrollPosition();
|
||||
const container = messagesContainerRef.current;
|
||||
if (container && activeChat) {
|
||||
const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 120;
|
||||
if (!isNearBottomNow) localStorage.removeItem(`chat_at_bottom_${activeChat}`);
|
||||
|
||||
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
||||
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 200);
|
||||
|
||||
if (container.scrollTop < 100 && hasMoreMessages[activeChat] && !isLoadingMessages) {
|
||||
useChatStore.getState().loadMessages(activeChat, false, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScrollEvent = (e: any) => {
|
||||
if (e.detail?.chatId === activeChat) {
|
||||
isScrollingToBottomRef.current = true;
|
||||
localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true');
|
||||
localStorage.removeItem(`chat_anchor_${activeChat}`);
|
||||
scrollToBottom(true);
|
||||
setTimeout(() => {
|
||||
isScrollingToBottomRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
window.addEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent);
|
||||
return () => window.removeEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent);
|
||||
}, [activeChat, scrollToBottom]);
|
||||
|
||||
// Read receipts using IntersectionObserver
|
||||
const sentReadIdsRef = useRef<Set<string>>(new Set());
|
||||
@@ -254,7 +415,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
useEffect(() => {
|
||||
if (!activeChat || !user?.id) return;
|
||||
|
||||
// Cleanup previous observer
|
||||
if (observerRef.current) observerRef.current.disconnect();
|
||||
sentReadIdsRef.current.clear();
|
||||
|
||||
@@ -286,7 +446,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
});
|
||||
|
||||
if (newlyReadIds.length > 0 && highestMsgId) {
|
||||
console.log('[IntersectionObserver] Marking as read up to:', highestSequenceId);
|
||||
socket.emit('read_messages', {
|
||||
chatId: activeChat,
|
||||
lastReadMessageId: highestMsgId,
|
||||
@@ -297,13 +456,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
},
|
||||
{
|
||||
root: messagesContainerRef.current,
|
||||
threshold: 0.1, // Message must be 10% visible
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
|
||||
observerRef.current = observer;
|
||||
|
||||
// Initial observation of unread messages
|
||||
const observeUnread = () => {
|
||||
if (!messagesContainerRef.current) return;
|
||||
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
||||
@@ -315,16 +473,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
});
|
||||
};
|
||||
|
||||
// Delay slight to let everything mount and be visible in DOM
|
||||
setTimeout(observeUnread, 100);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [activeChat, user?.id]);
|
||||
|
||||
// Re-run observation when messages change (to catch new arrivals)
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
if (!messagesContainerRef.current) return;
|
||||
if (observerRef.current && messagesContainerRef.current) {
|
||||
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
||||
unreadElements.forEach((el) => {
|
||||
const id = el.getAttribute('data-message-id');
|
||||
@@ -335,26 +489,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
}
|
||||
}, [chatMessages, scrollReady]);
|
||||
|
||||
// Scroll detection
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
const isNearBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
setShowScrollDown(!isNearBottom);
|
||||
}, []);
|
||||
|
||||
const handleScroll = () => {
|
||||
checkScrollPosition();
|
||||
|
||||
const container = messagesContainerRef.current;
|
||||
if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) {
|
||||
useChatStore.getState().loadMessages(activeChat, false, true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check scroll position when messages change or scroll ready state changes
|
||||
if (scrollReady) {
|
||||
checkScrollPosition();
|
||||
}
|
||||
@@ -998,23 +1133,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
<AnimatePresence>
|
||||
{showScrollDown && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
initial={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => {
|
||||
scrollToBottom(false);
|
||||
scrollToBottom(true);
|
||||
if (activeChat && unreadCount > 0) {
|
||||
useChatStore.getState().markAllAsRead(activeChat);
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-24 right-5 w-12 h-12 rounded-full bg-surface-secondary/95 backdrop-blur-md border border-border shadow-xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10"
|
||||
className="absolute bottom-24 right-8 w-14 h-14 rounded-2xl bg-gradient-to-br from-primary to-primary-container text-on-primary-container shadow-[0_8px_30px_rgba(48,150,229,0.3)] flex items-center justify-center transition-all z-10 border border-white/10 backdrop-blur-md"
|
||||
>
|
||||
<ArrowDown size={22} className="text-accent hover:text-accent-light transition-colors" />
|
||||
<span className="material-symbols-outlined text-3xl">arrow_downward</span>
|
||||
{unreadCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -top-1.5 -right-1.5 min-w-[20px] h-5 px-1.5 rounded-full bg-accent text-white text-[11px] font-bold flex items-center justify-center shadow-lg border-2 border-surface-secondary"
|
||||
className="absolute -top-2 -right-2 min-w-[24px] h-6 px-1.5 rounded-full bg-error text-on-error text-[12px] font-black flex items-center justify-center shadow-lg border-2 border-surface-container-lowest"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</motion.span>
|
||||
|
||||
Reference in New Issue
Block a user