11// @ts -check
2- import React , {
3- useContext ,
4- useState ,
5- useEffect ,
6- useCallback ,
7- useRef ,
8- } from 'react' ;
2+ import React , { useCallback , useContext , useMemo , useRef } from 'react' ;
93import { Virtuoso } from 'react-virtuoso' ;
10-
11- import { smartRender } from '../../utils' ;
12- import MessageNotification from './MessageNotification' ;
134import { ChannelContext , TranslationContext } from '../../context' ;
5+ import { smartRender } from '../../utils' ;
6+ import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator' ;
147import { EventComponent } from '../EventComponent' ;
158import { LoadingIndicator as DefaultLoadingIndicator } from '../Loading' ;
16- import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator' ;
179import {
18- FixedHeightMessage ,
10+ FixedHeightMessage as DefaultMessage ,
1911 MessageDeleted as DefaultMessageDeleted ,
2012} from '../Message' ;
13+ import { useNewMessageNotification } from './hooks/useNewMessageNotification' ;
14+ import { usePrependedMessagesCount } from './hooks/usePrependMessagesCount' ;
15+ import { useShouldForceScrollToBottom } from './hooks/useShouldForceScrollToBottom' ;
16+ import MessageNotification from './MessageNotification' ;
17+
18+ const PREPEND_OFFSET = 10 ** 7 ;
2119
2220/**
2321 * VirtualizedMessageList - This component renders a list of messages in a virtual list. Its a consumer of [Channel Context](https://getstream.github.io/stream-chat-react/#channel)
24- * It is pretty fast for rendering thousands of messages but it needs its Message component to have fixed height
2522 * @example ../../docs/VirtualizedMessageList.md
2623 * @type {React.FC<import('types').VirtualizedMessageListInternalProps> }
2724 */
@@ -32,91 +29,117 @@ const VirtualizedMessageList = ({
3229 hasMore,
3330 loadingMore,
3431 messageLimit = 100 ,
35- overscan = 200 ,
32+ overscan = 0 ,
3633 shouldGroupByUser = false ,
3734 customMessageRenderer,
35+ // TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component
3836 scrollSeekPlaceHolder,
39- Message = FixedHeightMessage ,
37+ Message = DefaultMessage ,
4038 MessageSystem = EventComponent ,
4139 MessageDeleted = DefaultMessageDeleted ,
4240 TypingIndicator = null ,
4341 LoadingIndicator = DefaultLoadingIndicator ,
4442 EmptyStateIndicator = DefaultEmptyStateIndicator ,
43+ stickToBottomScrollBehavior = 'smooth' ,
4544} ) => {
4645 const { t } = useContext ( TranslationContext ) ;
47- const [ newMessagesNotification , setNewMessagesNotification ] = useState ( false ) ;
4846
4947 const virtuoso = useRef (
50- /** @type {import('react-virtuoso').VirtuosoMethods | undefined } */ ( undefined ) ,
48+ /** @type {import('react-virtuoso').VirtuosoHandle | undefined } */ ( undefined ) ,
49+ ) ;
50+
51+ const {
52+ atBottom,
53+ setNewMessagesNotification,
54+ newMessagesNotification,
55+ } = useNewMessageNotification ( messages , client . userID ) ;
56+
57+ const numItemsPrepended = usePrependedMessagesCount ( messages ) ;
58+
59+ const shouldForceScrollToBottom = useShouldForceScrollToBottom (
60+ messages ,
61+ client . userID ,
5162 ) ;
52- const mounted = useRef ( false ) ;
53- const atBottom = useRef ( false ) ;
54- const lastMessageId = useRef ( '' ) ;
55-
56- useEffect ( ( ) => {
57- /* handle scrolling behavior for new messages */
58- if ( ! messages ?. length ) return ;
59-
60- const lastMessage = messages [ messages . length - 1 ] ;
61- const prevMessageId = lastMessageId . current ;
62- lastMessageId . current = lastMessage . id || '' ; // update last message id
63-
64- /* do nothing if new messages are loaded from top(loadMore) */
65- if ( lastMessage . id === prevMessageId ) return ;
66-
67- /* if list is already at the bottom return, followOutput will do the job */
68- if ( atBottom . current ) return ;
69-
70- /* if the new message belongs to current user scroll to bottom */
71- if ( lastMessage . user ?. id === client . userID ) {
72- setTimeout ( ( ) => virtuoso . current ?. scrollToIndex ( messages . length ) ) ;
73- return ;
74- }
75-
76- /* otherwise just show newMessage notification */
77- setNewMessagesNotification ( true ) ;
78- } , [ client . userID , messages ] ) ;
79-
80- useEffect ( ( ) => {
81- /*
82- * scroll to bottom when list is rendered for the first time
83- * this is due to initialTopMostItemIndex buggy behavior leading to empty screen
84- */
85- if ( mounted . current ) return ;
86- mounted . current = true ;
87- if ( messages ?. length && virtuoso . current ) {
88- virtuoso . current . scrollToIndex ( messages . length - 1 ) ;
89- }
90- } , [ messages ?. length ] ) ;
9163
9264 const messageRenderer = useCallback (
93- ( messageList , i ) => {
65+ ( messageList , virtuosoIndex ) => {
66+ const streamMessageIndex =
67+ virtuosoIndex + numItemsPrepended - PREPEND_OFFSET ;
9468 // use custom renderer supplied by client if present and skip the rest
95- if ( customMessageRenderer ) return customMessageRenderer ( messageList , i ) ;
69+ if ( customMessageRenderer ) {
70+ return customMessageRenderer ( messageList , streamMessageIndex ) ;
71+ }
72+
73+ const message = messageList [ streamMessageIndex ] ;
9674
97- const message = messageList [ i ] ;
9875 if ( ! message ) return < div style = { { height : '1px' } } > </ div > ; // returning null or zero height breaks the virtuoso
9976
100- if ( message . type === 'channel.event' || message . type === 'system' )
77+ if ( message . type === 'channel.event' || message . type === 'system' ) {
10178 return < MessageSystem message = { message } /> ;
79+ }
10280
103- if ( message . deleted_at )
81+ if ( message . deleted_at ) {
10482 return smartRender ( MessageDeleted , { message } , null ) ;
83+ }
10584
10685 return (
10786 < Message
10887 message = { message }
10988 groupedByUser = {
11089 shouldGroupByUser &&
111- i > 0 &&
112- message . user . id === messageList [ i - 1 ] . user . id
90+ streamMessageIndex > 0 &&
91+ message . user . id === messageList [ streamMessageIndex - 1 ] . user . id
11392 }
11493 />
11594 ) ;
11695 } ,
117- [ MessageDeleted , customMessageRenderer , shouldGroupByUser ] ,
96+ [
97+ MessageDeleted ,
98+ customMessageRenderer ,
99+ shouldGroupByUser ,
100+ numItemsPrepended ,
101+ ] ,
118102 ) ;
119103
104+ const virtuosoComponents = useMemo ( ( ) => {
105+ const EmptyPlaceholder = ( ) => < EmptyStateIndicator listType = "message" /> ;
106+ const Header = ( ) =>
107+ loadingMore ? (
108+ < div className = "str-chat__virtual-list__loading" >
109+ < LoadingIndicator size = { 20 } />
110+ </ div >
111+ ) : (
112+ < > </ >
113+ ) ;
114+
115+ /**
116+ * using 'display: inline-block' traps CSS margins of the item elements, preventing incorrect item measurements.
117+ * @type {import('react-virtuoso').Components['Item'] }
118+ */
119+ const Item = ( props ) => {
120+ return (
121+ < div
122+ { ...props }
123+ style = { {
124+ display : 'inline-block' ,
125+ width : '100%' ,
126+ } }
127+ />
128+ ) ;
129+ } ;
130+
131+ const Footer = ( ) => {
132+ return TypingIndicator ? < TypingIndicator avatarSize = { 24 } /> : < > </ > ;
133+ } ;
134+
135+ return {
136+ EmptyPlaceholder,
137+ Header,
138+ Footer,
139+ Item,
140+ } ;
141+ } , [ EmptyStateIndicator , loadingMore , TypingIndicator ] ) ;
142+
120143 if ( ! messages ) {
121144 return null ;
122145 }
@@ -128,44 +151,45 @@ const VirtualizedMessageList = ({
128151 ref = { virtuoso }
129152 totalCount = { messages . length }
130153 overscan = { overscan }
131- followOutput = { true }
132- maxHeightCacheSize = { 2000 } // reset the cache once it reaches 2k
133- scrollSeek = { scrollSeekPlaceHolder }
134- item = { ( i ) => messageRenderer ( messages , i ) }
135- emptyComponent = { ( ) => < EmptyStateIndicator listType = "message" /> }
136- header = { ( ) =>
137- loadingMore ? (
138- < div className = "str-chat__virtual-list__loading" >
139- < LoadingIndicator size = { 20 } />
140- </ div >
141- ) : (
142- < > </ >
143- )
144- }
145- footer = { ( ) =>
146- TypingIndicator ? < TypingIndicator avatarSize = { 24 } /> : < > </ >
147- }
154+ style = { { overflowX : 'hidden' } }
155+ followOutput = { ( isAtBottom ) => {
156+ if ( shouldForceScrollToBottom ( ) ) {
157+ return isAtBottom ? stickToBottomScrollBehavior : 'auto' ;
158+ }
159+ // a message from another user has been received - don't scroll to bottom unless already there
160+ return isAtBottom ? stickToBottomScrollBehavior : false ;
161+ } }
162+ itemContent = { ( i ) => {
163+ return messageRenderer ( messages , i ) ;
164+ } }
165+ components = { virtuosoComponents }
166+ firstItemIndex = { PREPEND_OFFSET - numItemsPrepended }
148167 startReached = { ( ) => {
149- // mounted.current prevents immediate loadMore on first render
150- if ( mounted . current && hasMore ) {
151- loadMore ( messageLimit ) . then (
152- virtuoso . current ?. adjustForPrependedItems ,
153- ) ;
168+ if ( hasMore ) {
169+ loadMore ( messageLimit ) ;
154170 }
155171 } }
172+ initialTopMostItemIndex = {
173+ messages && messages . length > 0 ? messages . length - 1 : 0
174+ }
156175 atBottomStateChange = { ( isAtBottom ) => {
157176 atBottom . current = isAtBottom ;
158- if ( isAtBottom && newMessagesNotification )
177+ if ( isAtBottom && newMessagesNotification ) {
159178 setNewMessagesNotification ( false ) ;
179+ }
160180 } }
181+ { ...( scrollSeekPlaceHolder
182+ ? { scrollSeek : scrollSeekPlaceHolder }
183+ : { } ) }
161184 />
162185
163186 < div className = "str-chat__list-notifications" >
164187 < MessageNotification
165188 showNotification = { newMessagesNotification }
166189 onClick = { ( ) => {
167- if ( virtuoso . current )
168- virtuoso . current . scrollToIndex ( messages . length ) ;
190+ if ( virtuoso . current ) {
191+ virtuoso . current . scrollToIndex ( messages . length - 1 ) ;
192+ }
169193 setNewMessagesNotification ( false ) ;
170194 } }
171195 >
@@ -186,7 +210,7 @@ export default function VirtualizedMessageListWithContext(props) {
186210 return (
187211 < ChannelContext . Consumer >
188212 { (
189- /* {Required<Pick<import('types').ChannelContextValue, 'client' | 'messages' | 'loadMore' | 'hasMore' | 'loadingMore' >>} */ context ,
213+ /* {Required<Pick<import('types').ChannelContextValue, 'client' | 'messages' | 'loadMore' | 'hasMore'>>} */ context ,
190214 ) => (
191215 < VirtualizedMessageList
192216 client = { context . client }
0 commit comments