Files
jitsi-meet/react/features/chat/components/web/SubtitlesMessagesContainer.tsx
Hristo Terezov 86e641c2a1 fix(chat): scroll to latest messages when opening chat or CC tabs
Chat and CC panels showed oldest messages at the top when opened because
scrollIntoView was called on hidden (display: none) elements where it
silently does nothing. Now we skip scrolling on mount for hidden tabs
and defer it to when each tab first becomes visible, while preserving
the user's scroll position on subsequent tab switches.
2026-03-06 06:46:42 -06:00

179 lines
5.6 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { scrollIntoView } from 'seamless-scroll-polyfill';
import { makeStyles } from 'tss-react/mui';
import { ISubtitle } from '../../../subtitles/types';
import NewMessagesButton from './NewMessagesButton';
import { SubtitlesGroup } from './SubtitlesGroup';
interface IProps {
groups: Array<{
messages: ISubtitle[];
senderId: string;
}>;
/**
* Whether the subtitles container is currently visible to the user.
*/
isVisible: boolean;
messages: ISubtitle[];
}
/**
* The padding value used for the message list.
*
* @constant {string}
*/
const MESSAGE_LIST_PADDING = '16px';
const useStyles = makeStyles()(() => {
return {
container: {
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%'
},
messagesList: {
height: '100%',
overflowY: 'auto',
padding: MESSAGE_LIST_PADDING,
boxSizing: 'border-box'
}
};
});
/**
* Component that handles the display and scrolling behavior of subtitles messages.
* It provides auto-scrolling for new messages and a button to jump to new messages
* when the user has scrolled up.
*
* @returns {JSX.Element} - A React component displaying subtitles messages with scroll functionality.
*/
export function SubtitlesMessagesContainer({ messages, groups, isVisible }: IProps) {
const { classes } = useStyles();
const [ hasNewMessages, setHasNewMessages ] = useState(false);
const [ isScrolledToBottom, setIsScrolledToBottom ] = useState(true);
const [ observer, setObserver ] = useState<IntersectionObserver | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const hasInitiallyScrolled = useRef(false);
const scrollToElement = useCallback((withAnimation: boolean, element: Element | null) => {
const scrollTo = element ? element : messagesEndRef.current;
const block = element ? 'end' : 'nearest';
scrollIntoView(scrollTo as Element, {
behavior: withAnimation ? 'smooth' : 'auto',
block
});
}, [ messagesEndRef.current ]);
const handleNewMessagesClick = useCallback(() => {
scrollToElement(true, null);
}, [ scrollToElement ]);
const handleIntersectBottomList = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting) {
setIsScrolledToBottom(true);
setHasNewMessages(false);
}
if (!entry.isIntersecting) {
setIsScrolledToBottom(false);
}
});
};
const createBottomListObserver = () => {
const target = document.querySelector('#subtitles-messages-end');
if (target) {
const newObserver = new IntersectionObserver(
handleIntersectBottomList, {
root: document.querySelector('#subtitles-messages-list'),
rootMargin: MESSAGE_LIST_PADDING,
threshold: 1
});
setObserver(newObserver);
newObserver.observe(target);
}
};
useEffect(() => {
// Only scroll on mount if the component is visible, since scrollIntoView
// silently does nothing on hidden (display: none) elements. If hidden,
// the isVisible useEffect below will handle scrolling when it first becomes visible.
if (isVisible) {
hasInitiallyScrolled.current = true;
requestAnimationFrame(() => {
scrollToElement(false, null);
});
}
createBottomListObserver();
return () => {
if (observer) {
observer.disconnect();
setObserver(null);
}
};
}, []);
useEffect(() => {
if (isVisible && !hasInitiallyScrolled.current) {
hasInitiallyScrolled.current = true;
requestAnimationFrame(() => {
scrollToElement(false, null);
});
}
}, [ isVisible, scrollToElement ]);
const previousMessages = useRef(messages);
useEffect(() => {
const newMessages = messages.filter(message => !previousMessages.current.includes(message));
if (newMessages.length > 0) {
if (isScrolledToBottom) {
scrollToElement(false, null);
} else {
setHasNewMessages(true);
}
}
previousMessages.current = messages;
},
// isScrolledToBottom is not a dependency because we neither need to show the new messages button neither scroll to the
// bottom when the user has scrolled up.
[ messages, scrollToElement ]);
return (
<div
className = { classes.container }
id = 'subtitles-messages-container'>
<div
className = { classes.messagesList }
id = 'subtitles-messages-list'>
{groups.map(group => (
<SubtitlesGroup
key = { `${group.senderId}-${group.messages[0].timestamp}` }
messages = { group.messages }
senderId = { group.senderId } />
))}
{ !isScrolledToBottom && hasNewMessages && (
<NewMessagesButton
onGoToFirstUnreadMessage = { handleNewMessagesClick } />
)}
<div
id = 'subtitles-messages-end'
ref = { messagesEndRef } />
</div>
</div>
);
}