Skip to content

Commit 7220217

Browse files
chohongmbang9HoonBaek
authored
bug-fix: Added and applied useOnScrollReachedEndDetector() in MessageList to mark new messages as read when scroll reaches bottom. (#848)
Fixes: [CLNP-1524](https://sendbird.atlassian.net/browse/CLNP-1524) ### Changelog - Added and applied `useOnScrollReachedEndDetector()` in `MessageList` to mark new messages as read when scroll reaches bottom - Channel list no longer displays unread message count for focused channel The second change was due to limitation where isScrollBottom and hasNext states needs to be added globally but they are from channel context so channel list cannot see them with the current architecture. [CLNP-1524]: https://sendbird.atlassian.net/browse/CLNP-1524?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Hyungu Kang | Airen <[email protected]> Co-authored-by: Baek EunSeo <[email protected]>
1 parent 6fbbdc4 commit 7220217

File tree

5 files changed

+257
-28
lines changed

5 files changed

+257
-28
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React from 'react';
2+
import { renderHook } from '@testing-library/react';
3+
import { useOnScrollPositionChangeDetector, UseOnScrollReachedEndDetectorProps } from '../index';
4+
import { SCROLL_BUFFER } from '../../../utils/consts';
5+
6+
jest.useFakeTimers();
7+
8+
const SAFE_DELAY = 550;
9+
10+
const prepareMockParams = (): UseOnScrollReachedEndDetectorProps => {
11+
return {
12+
onReachedTop: jest.fn(),
13+
onReachedBottom: jest.fn(),
14+
onInBetween: jest.fn(),
15+
};
16+
};
17+
18+
const getMockScrollEvent = ({
19+
scrollTop = 0,
20+
scrollHeight = 0,
21+
clientHeight = 0,
22+
}): React.UIEvent<HTMLDivElement> => {
23+
return {
24+
target: {
25+
scrollTop,
26+
scrollHeight,
27+
clientHeight,
28+
},
29+
} as unknown as React.UIEvent<HTMLDivElement>;
30+
};
31+
32+
describe('useOnScrollReachedEndDetector', () => {
33+
it('should call onReachedTop() when scrollTop is SCROLL_BUFFER', () => {
34+
const params = prepareMockParams();
35+
36+
const { result } = renderHook(() => useOnScrollPositionChangeDetector(params));
37+
const onScrollReachedEndDetector = result.current;
38+
onScrollReachedEndDetector(getMockScrollEvent({
39+
scrollTop: SCROLL_BUFFER,
40+
clientHeight: 100,
41+
scrollHeight: 200,
42+
}));
43+
44+
jest.advanceTimersByTime(SAFE_DELAY);
45+
46+
expect(params.onReachedTop).toHaveBeenCalledTimes(1);
47+
expect(params.onReachedBottom).not.toHaveBeenCalled();
48+
expect(params.onInBetween).not.toHaveBeenCalled();
49+
});
50+
it('should call onReachedTop() when scrollTop < SCROLL_BUFFER', () => {
51+
const params = prepareMockParams();
52+
53+
const { result } = renderHook(() => useOnScrollPositionChangeDetector(params));
54+
const onScrollReachedEndDetector = result.current;
55+
onScrollReachedEndDetector(getMockScrollEvent({
56+
scrollTop: 5,
57+
clientHeight: 100,
58+
scrollHeight: 200,
59+
}));
60+
61+
jest.advanceTimersByTime(SAFE_DELAY);
62+
63+
expect(params.onReachedTop).toHaveBeenCalledTimes(1);
64+
expect(params.onReachedBottom).not.toHaveBeenCalled();
65+
expect(params.onInBetween).not.toHaveBeenCalled();
66+
});
67+
it('should call onReachedTop() when scrollTop is 0', () => {
68+
const params = prepareMockParams();
69+
70+
const { result } = renderHook(() => useOnScrollPositionChangeDetector(params));
71+
const onScrollReachedEndDetector = result.current;
72+
onScrollReachedEndDetector(getMockScrollEvent({
73+
scrollTop: 0,
74+
clientHeight: 100,
75+
scrollHeight: 200,
76+
}));
77+
78+
jest.advanceTimersByTime(SAFE_DELAY);
79+
80+
expect(params.onReachedTop).toHaveBeenCalledTimes(1);
81+
expect(params.onReachedBottom).not.toHaveBeenCalled();
82+
expect(params.onInBetween).not.toHaveBeenCalled();
83+
});
84+
it('should call onReachedBottom() when scrollHeight - (clientHeight + scrollTop) is SCROLL_BUFFER', () => {
85+
const params = prepareMockParams();
86+
87+
const { result } = renderHook(() => useOnScrollPositionChangeDetector(params));
88+
const onScrollReachedEndDetector = result.current;
89+
onScrollReachedEndDetector(getMockScrollEvent({
90+
scrollTop: 90,
91+
clientHeight: 100,
92+
scrollHeight: 200,
93+
}));
94+
95+
jest.advanceTimersByTime(SAFE_DELAY);
96+
97+
expect(params.onReachedTop).not.toHaveBeenCalled();
98+
expect(params.onReachedBottom).toHaveBeenCalledTimes(1);
99+
expect(params.onInBetween).not.toHaveBeenCalled();
100+
});
101+
it('should call onReachedBottom() when scrollHeight - (clientHeight + scrollTop) < SCROLL_BUFFER', () => {
102+
const params = prepareMockParams();
103+
104+
const { result } = renderHook(() => useOnScrollPositionChangeDetector(params));
105+
const onScrollReachedEndDetector = result.current;
106+
onScrollReachedEndDetector(getMockScrollEvent({
107+
scrollTop: 95,
108+
clientHeight: 100,
109+
scrollHeight: 200,
110+
}));
111+
112+
jest.advanceTimersByTime(SAFE_DELAY);
113+
114+
expect(params.onReachedTop).not.toHaveBeenCalled();
115+
expect(params.onReachedBottom).toHaveBeenCalledTimes(1);
116+
expect(params.onInBetween).not.toHaveBeenCalled();
117+
});
118+
it('should call onReachedBottom() when scrollHeight - (clientHeight + scrollTop) is 0', () => {
119+
const params = prepareMockParams();
120+
121+
const { result } = renderHook(() => useOnScrollPositionChangeDetector(params));
122+
const onScrollReachedEndDetector = result.current;
123+
onScrollReachedEndDetector(getMockScrollEvent({
124+
scrollTop: 100,
125+
clientHeight: 100,
126+
scrollHeight: 200,
127+
}));
128+
129+
jest.advanceTimersByTime(SAFE_DELAY);
130+
131+
expect(params.onReachedTop).not.toHaveBeenCalled();
132+
expect(params.onReachedBottom).toHaveBeenCalledTimes(1);
133+
expect(params.onInBetween).not.toHaveBeenCalled();
134+
});
135+
it('should call onReachedBottom() when scroll position has not reached either ends', () => {
136+
const params = prepareMockParams();
137+
138+
const { result } = renderHook(() => useOnScrollPositionChangeDetector(params));
139+
const onScrollReachedEndDetector = result.current;
140+
onScrollReachedEndDetector(getMockScrollEvent({
141+
scrollTop: 50,
142+
clientHeight: 100,
143+
scrollHeight: 200,
144+
}));
145+
146+
jest.advanceTimersByTime(SAFE_DELAY);
147+
148+
expect(params.onReachedTop).not.toHaveBeenCalled();
149+
expect(params.onReachedBottom).not.toHaveBeenCalled();
150+
expect(params.onInBetween).toHaveBeenCalledTimes(1);
151+
});
152+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react';
2+
import { SCROLL_BUFFER } from '../../utils/consts';
3+
import { isAboutSame } from '../../modules/Channel/context/utils';
4+
import { useDebounce } from '../useDebounce';
5+
import { usePreservedCallback } from '@sendbird/uikit-tools';
6+
7+
const BUFFER_DELAY = 500;
8+
9+
export interface UseOnScrollReachedEndDetectorProps {
10+
onReachedTop?: () => void;
11+
onReachedBottom?: () => void;
12+
onInBetween?: () => void;
13+
}
14+
15+
export function useOnScrollPositionChangeDetector(
16+
props: UseOnScrollReachedEndDetectorProps,
17+
): (event: React.UIEvent<HTMLDivElement, UIEvent>) => void {
18+
const {
19+
onReachedTop,
20+
onReachedBottom,
21+
onInBetween,
22+
} = props;
23+
24+
const cb = usePreservedCallback((event: React.UIEvent<HTMLDivElement, UIEvent>) => {
25+
if (event?.target) {
26+
const {
27+
scrollTop,
28+
scrollHeight,
29+
clientHeight,
30+
} = event.target as HTMLDivElement;
31+
if (isAboutSame(scrollTop, 0, SCROLL_BUFFER)) {
32+
onReachedTop();
33+
} else if (isAboutSame(scrollHeight, clientHeight + scrollTop, SCROLL_BUFFER)) {
34+
onReachedBottom();
35+
} else {
36+
onInBetween();
37+
}
38+
}
39+
});
40+
41+
return useDebounce(cb, BUFFER_DELAY);
42+
}

src/modules/Channel/components/Message/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ const Message = ({
142142

143143
useLayoutEffect(() => {
144144
// Keep the scrollBottom value after fetching new message list
145-
handleScroll?.();
145+
handleScroll?.(true);
146146
}, []);
147147
/**
148148
* Move the messsage list scroll

src/modules/Channel/components/MessageList/index.tsx

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import './message-list.scss';
22

3-
import React from 'react';
3+
import React, { useState } from 'react';
44

55
import { useChannelContext } from '../../context/ChannelProvider';
66
import PlaceHolder, { PlaceHolderTypes } from '../../../../ui/PlaceHolder';
7-
import Icon, { IconTypes, IconColors } from '../../../../ui/Icon';
7+
import Icon, { IconColors, IconTypes } from '../../../../ui/Icon';
88
import Message from '../Message';
99
import { EveryMessage, RenderCustomSeparatorProps, RenderMessageProps, TypingIndicatorType } from '../../../../types';
10+
import * as utils from '../../context/utils';
1011
import { isAboutSame } from '../../context/utils';
1112
import { getMessagePartsInfo } from './getMessagePartsInfo';
1213
import UnreadCount from '../UnreadCount';
@@ -18,8 +19,8 @@ import { MessageProvider } from '../../../Message/context/MessageProvider';
1819
import { useHandleOnScrollCallback } from '../../../../hooks/useHandleOnScrollCallback';
1920
import { useSetScrollToBottom } from './hooks/useSetScrollToBottom';
2021
import { useScrollBehavior } from './hooks/useScrollBehavior';
21-
import * as utils from '../../context/utils';
2222
import TypingIndicatorBubble from '../../../../ui/TypingIndicatorBubble';
23+
import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector';
2324

2425
const SCROLL_BOTTOM_PADDING = 50;
2526

@@ -71,6 +72,8 @@ const MessageList: React.FC<MessageListProps> = ({
7172
: allMessages;
7273
const markAsReadScheduler = store.config.markAsReadScheduler;
7374

75+
const [isScrollBottom, setIsScrollBottom] = useState(false);
76+
7477
useScrollBehavior();
7578

7679
const onScroll = () => {
@@ -154,6 +157,29 @@ const MessageList: React.FC<MessageListProps> = ({
154157
scrollRef,
155158
});
156159

160+
const onScrollReachedEndDetector = useOnScrollPositionChangeDetector({
161+
onReachedBottom: () => {
162+
/**
163+
* Note that this event is already being called in onScroll() above. However, it is only being called when
164+
* hasMoreNext is true but it needs to be called when hasNext is false when reached bottom as well.
165+
*/
166+
if (!hasMoreNext && !disableMarkAsRead && !!currentGroupChannel) {
167+
messagesDispatcher({
168+
type: messageActionTypes.MARK_AS_READ,
169+
payload: { channel: currentGroupChannel },
170+
});
171+
markAsReadScheduler.push(currentGroupChannel);
172+
}
173+
setIsScrollBottom(true);
174+
},
175+
onReachedTop: () => {
176+
setIsScrollBottom(false);
177+
},
178+
onInBetween: () => {
179+
setIsScrollBottom(false);
180+
},
181+
});
182+
157183
const { scrollToBottomHandler, scrollBottom } = useSetScrollToBottom({ loading });
158184

159185
if (loading) {
@@ -182,6 +208,7 @@ const MessageList: React.FC<MessageListProps> = ({
182208
onScroll={(e) => {
183209
handleOnScroll();
184210
scrollToBottomHandler(e);
211+
onScrollReachedEndDetector(e);
185212
}}
186213
>
187214
{
@@ -262,29 +289,32 @@ const MessageList: React.FC<MessageListProps> = ({
262289
: <FrozenNotification className="sendbird-conversation__messages__notification" />
263290
)
264291
}
265-
{(unreadSince || unreadSinceDate) && (
266-
<UnreadCount
267-
className="sendbird-conversation__messages__notification"
268-
count={currentGroupChannel?.unreadMessageCount}
269-
time={unreadSince}
270-
lastReadAt={unreadSinceDate}
271-
onClick={() => {
272-
if (scrollRef?.current?.scrollTop) {
273-
scrollRef.current.scrollTop = (scrollRef?.current?.scrollHeight ?? 0) - (scrollRef?.current?.offsetHeight ?? 0);
274-
}
275-
if (!disableMarkAsRead && !!currentGroupChannel) {
276-
markAsReadScheduler.push(currentGroupChannel);
277-
messagesDispatcher({
278-
type: messageActionTypes.MARK_AS_READ,
279-
payload: { channel: currentGroupChannel },
280-
});
281-
}
282-
setInitialTimeStamp(null);
283-
setAnimatedMessageId(null);
284-
setHighLightedMessageId(null);
285-
}}
286-
/>
287-
)}
292+
{
293+
/**
294+
* Show unread count IFF scroll is not bottom or is bottom but hasNext is true.
295+
*/
296+
(!isScrollBottom || hasMoreNext) && (unreadSince || unreadSinceDate) && (
297+
<UnreadCount
298+
className="sendbird-conversation__messages__notification"
299+
count={currentGroupChannel?.unreadMessageCount}
300+
time={unreadSince}
301+
lastReadAt={unreadSinceDate}
302+
onClick={() => {
303+
if (scrollRef?.current) scrollRef.current.scrollTop = Number.MAX_SAFE_INTEGER;
304+
if (!disableMarkAsRead && !!currentGroupChannel) {
305+
markAsReadScheduler.push(currentGroupChannel);
306+
messagesDispatcher({
307+
type: messageActionTypes.MARK_AS_READ,
308+
payload: { channel: currentGroupChannel },
309+
});
310+
}
311+
setInitialTimeStamp(null);
312+
setAnimatedMessageId(null);
313+
setHighLightedMessageId(null);
314+
}}
315+
/>
316+
)
317+
}
288318
{
289319
// This flag is an unmatched variable
290320
scrollBottom > SCROLL_BOTTOM_PADDING && (

src/modules/ChannelList/components/ChannelPreview/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,12 @@ const ChannelPreview: React.FC<ChannelPreviewInterface> = ({
186186
}
187187
</Label>
188188
{
189-
!channel?.isEphemeral && (
189+
/**
190+
* Do not show unread count for focused channel. This is because of the limitation where
191+
* isScrollBottom and hasNext states needs to be added globally but they are from channel context
192+
* so channel list cannot see them with the current architecture.
193+
*/
194+
!isActive && !channel?.isEphemeral && (
190195
<div className="sendbird-channel-preview__content__lower__unread-message-count">
191196
{
192197
(isMentionEnabled && channel?.unreadMentionCount > 0)

0 commit comments

Comments
 (0)