Skip to content

Commit 4da1ff0

Browse files
authored
Merge pull request #141 from sendbird/feat/scroll-to-message-with-id
feat(UIKIT-4566): add scrollToMessage to group channel contexts
2 parents b43552d + 4a6efdc commit 4da1ff0

File tree

5 files changed

+189
-51
lines changed

5 files changed

+189
-51
lines changed

packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx

Lines changed: 29 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import React, { useContext, useEffect, useRef } from 'react';
2-
import type { FlatList } from 'react-native';
1+
import React, { useContext, useEffect } from 'react';
32

43
import { useChannelHandler } from '@sendbird/uikit-chat-hooks';
54
import { useToast } from '@sendbird/uikit-react-native-foundation';
@@ -18,45 +17,32 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => {
1817
const { sdk } = useSendbirdChat();
1918
const { setMessageToEdit, setMessageToReply } = useContext(GroupChannelContexts.Fragment);
2019
const { subscribe } = useContext(GroupChannelContexts.PubSub);
20+
const { flatListRef, lazyScrollToBottom, lazyScrollToIndex } = useContext(GroupChannelContexts.MessageList);
2121

2222
const id = useUniqHandlerId('GroupChannelMessageList');
23-
const ref = useRef<FlatList<SendbirdMessage>>(null);
2423
const isFirstMount = useIsFirstMount();
2524

26-
// FIXME: Workaround, should run after data has been applied to UI.
27-
const lazyScrollToBottom = (animated = false, timeout = 0) => {
28-
setTimeout(() => {
29-
ref.current?.scrollToOffset({ offset: 0, animated });
30-
}, timeout);
31-
};
25+
const scrollToMessageWithCreatedAt = useFreshCallback(
26+
(createdAt: number, focusAnimated: boolean, timeout: number): boolean => {
27+
const foundMessageIndex = props.messages.findIndex((it) => it.createdAt === createdAt);
28+
const isIncludedInList = foundMessageIndex > -1;
3229

33-
// FIXME: Workaround, should run after data has been applied to UI.
34-
const lazyScrollToIndex = (index = 0, animated = false, timeout = 0) => {
35-
setTimeout(() => {
36-
ref.current?.scrollToIndex({ index, animated, viewPosition: 0.5 });
37-
}, timeout);
38-
};
39-
40-
const scrollToMessage = useFreshCallback((createdAt: number, focusAnimated = false): boolean => {
41-
const foundMessageIndex = props.messages.findIndex((it) => it.createdAt === createdAt);
42-
const isIncludedInList = foundMessageIndex > -1;
43-
44-
if (isIncludedInList) {
45-
if (focusAnimated) {
46-
setTimeout(() => props.onUpdateSearchItem({ startingPoint: createdAt }), MESSAGE_FOCUS_ANIMATION_DELAY);
47-
}
48-
lazyScrollToIndex(foundMessageIndex, true, isFirstMount ? MESSAGE_SEARCH_SAFE_SCROLL_DELAY : 0);
49-
} else {
50-
if (props.channel.messageOffsetTimestamp <= createdAt) {
51-
if (focusAnimated) props.onUpdateSearchItem({ startingPoint: createdAt });
52-
props.onResetMessageListWithStartingPoint(createdAt);
30+
if (isIncludedInList) {
31+
if (focusAnimated) {
32+
setTimeout(() => props.onUpdateSearchItem({ startingPoint: createdAt }), MESSAGE_FOCUS_ANIMATION_DELAY);
33+
}
34+
lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout });
5335
} else {
54-
return false;
36+
if (props.channel.messageOffsetTimestamp <= createdAt) {
37+
if (focusAnimated) props.onUpdateSearchItem({ startingPoint: createdAt });
38+
props.onResetMessageListWithStartingPoint(createdAt);
39+
} else {
40+
return false;
41+
}
5542
}
56-
}
57-
58-
return true;
59-
});
43+
return true;
44+
},
45+
);
6046

6147
const scrollToBottom = useFreshCallback((animated = false) => {
6248
if (props.hasNext()) {
@@ -65,10 +51,10 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => {
6551

6652
props.onResetMessageList(() => {
6753
props.onScrolledAwayFromBottom(false);
68-
lazyScrollToBottom(animated);
54+
lazyScrollToBottom({ animated });
6955
});
7056
} else {
71-
lazyScrollToBottom(animated);
57+
lazyScrollToBottom({ animated });
7258
}
7359
});
7460

@@ -79,7 +65,7 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => {
7965
const isRecentMessage = recentMessage && recentMessage.messageId === event.messageId;
8066
const scrollReachedBottomAndCanScroll = !props.scrolledAwayFromBottom && !props.hasNext();
8167
if (isRecentMessage && scrollReachedBottomAndCanScroll) {
82-
lazyScrollToBottom(true, 250);
68+
lazyScrollToBottom({ animated: true, timeout: 250 });
8369
}
8470
},
8571
});
@@ -102,24 +88,24 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => {
10288
});
10389
}, [props.scrolledAwayFromBottom]);
10490

105-
// Only trigger once when message list mount with initial props.searchItem
106-
// - Search screen + searchItem > mount message list
107-
// - Reset message list + searchItem > re-mount message list
10891
useEffect(() => {
92+
// Only trigger once when message list mount with initial props.searchItem
93+
// - Search screen + searchItem > mount message list
94+
// - Reset message list + searchItem > re-mount message list
10995
if (isFirstMount && props.searchItem) {
110-
scrollToMessage(props.searchItem.startingPoint);
96+
scrollToMessageWithCreatedAt(props.searchItem.startingPoint, false, MESSAGE_SEARCH_SAFE_SCROLL_DELAY);
11197
}
11298
}, [isFirstMount]);
11399

114100
const onPressParentMessage = useFreshCallback((message: SendbirdMessage) => {
115-
const canScrollToParent = scrollToMessage(message.createdAt, true);
101+
const canScrollToParent = scrollToMessageWithCreatedAt(message.createdAt, true, 0);
116102
if (!canScrollToParent) toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error');
117103
});
118104

119105
return (
120106
<ChannelMessageList
121107
{...props}
122-
ref={ref}
108+
ref={flatListRef}
123109
onReplyMessage={setMessageToReply}
124110
onEditMessage={setMessageToEdit}
125111
onPressParentMessage={onPressParentMessage}

packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1-
import React, { createContext, useCallback, useState } from 'react';
1+
import React, { createContext, useCallback, useRef, useState } from 'react';
2+
import type { FlatList } from 'react-native';
23

34
import { useChannelHandler } from '@sendbird/uikit-chat-hooks';
45
import {
6+
ContextValue,
7+
Logger,
58
NOOP,
69
SendbirdFileMessage,
710
SendbirdGroupChannel,
11+
SendbirdMessage,
812
SendbirdUser,
913
SendbirdUserMessage,
1014
isDifferentChannel,
15+
useFreshCallback,
1116
useUniqHandlerId,
1217
} from '@sendbird/uikit-utils';
1318

1419
import ProviderLayout from '../../../components/ProviderLayout';
20+
import { MESSAGE_FOCUS_ANIMATION_DELAY } from '../../../constants';
1521
import { useLocalization, useSendbirdChat } from '../../../hooks/useContext';
1622
import type { PubSub } from '../../../utils/pubsub';
1723
import type { GroupChannelContextsType, GroupChannelModule, GroupChannelPubSubContextPayload } from '../types';
24+
import { GroupChannelProps } from '../types';
1825

1926
export const GroupChannelContexts: GroupChannelContextsType = {
2027
Fragment: createContext({
@@ -30,6 +37,16 @@ export const GroupChannelContexts: GroupChannelContextsType = {
3037
publish: NOOP,
3138
subscribe: () => NOOP,
3239
} as PubSub<GroupChannelPubSubContextPayload>),
40+
MessageList: createContext({
41+
flatListRef: { current: null },
42+
scrollToMessage: () => false,
43+
lazyScrollToBottom: () => {
44+
// noop
45+
},
46+
lazyScrollToIndex: () => {
47+
// noop
48+
},
49+
} as MessageListContextValue),
3350
};
3451

3552
export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({
@@ -38,6 +55,8 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({
3855
enableTypingIndicator,
3956
keyboardAvoidOffset = 0,
4057
groupChannelPubSub,
58+
messages,
59+
onUpdateSearchItem,
4160
}) => {
4261
if (!channel) throw new Error('GroupChannel is not provided to GroupChannelModule');
4362

@@ -49,6 +68,11 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({
4968
const [messageToEdit, setMessageToEdit] = useState<SendbirdUserMessage | SendbirdFileMessage>();
5069
const [messageToReply, setMessageToReply] = useState<SendbirdUserMessage | SendbirdFileMessage>();
5170

71+
const { flatListRef, lazyScrollToIndex, lazyScrollToBottom, scrollToMessage } = useScrollActions({
72+
messages,
73+
onUpdateSearchItem,
74+
});
75+
5276
const updateInputMode = (mode: 'send' | 'edit' | 'reply', message?: SendbirdUserMessage | SendbirdFileMessage) => {
5377
if (mode === 'send' || !message) {
5478
setMessageToEdit(undefined);
@@ -101,12 +125,97 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({
101125
setMessageToReply: useCallback((message) => updateInputMode('reply', message), []),
102126
}}
103127
>
104-
<GroupChannelContexts.TypingIndicator.Provider value={{ typingUsers }}>
105-
<GroupChannelContexts.PubSub.Provider value={groupChannelPubSub}>
106-
{children}
107-
</GroupChannelContexts.PubSub.Provider>
108-
</GroupChannelContexts.TypingIndicator.Provider>
128+
<GroupChannelContexts.PubSub.Provider value={groupChannelPubSub}>
129+
<GroupChannelContexts.TypingIndicator.Provider value={{ typingUsers }}>
130+
<GroupChannelContexts.MessageList.Provider
131+
value={{
132+
flatListRef,
133+
scrollToMessage,
134+
lazyScrollToIndex,
135+
lazyScrollToBottom,
136+
}}
137+
>
138+
{children}
139+
</GroupChannelContexts.MessageList.Provider>
140+
</GroupChannelContexts.TypingIndicator.Provider>
141+
</GroupChannelContexts.PubSub.Provider>
109142
</GroupChannelContexts.Fragment.Provider>
110143
</ProviderLayout>
111144
);
112145
};
146+
147+
type MessageListContextValue = ContextValue<GroupChannelContextsType['MessageList']>;
148+
const useScrollActions = (params: Pick<GroupChannelProps['Provider'], 'messages' | 'onUpdateSearchItem'>) => {
149+
const { messages, onUpdateSearchItem } = params;
150+
const flatListRef = useRef<FlatList<SendbirdMessage>>(null);
151+
152+
// FIXME: Workaround, should run after data has been applied to UI.
153+
const lazyScrollToBottom = useFreshCallback<MessageListContextValue['lazyScrollToIndex']>((params) => {
154+
if (!flatListRef.current) {
155+
logFlatListRefWarning();
156+
return;
157+
}
158+
159+
setTimeout(() => {
160+
flatListRef.current?.scrollToOffset({ offset: 0, animated: params?.animated ?? false });
161+
}, params?.timeout ?? 0);
162+
});
163+
164+
// FIXME: Workaround, should run after data has been applied to UI.
165+
const lazyScrollToIndex = useFreshCallback<MessageListContextValue['lazyScrollToIndex']>((params) => {
166+
if (!flatListRef.current) {
167+
logFlatListRefWarning();
168+
return;
169+
}
170+
171+
setTimeout(() => {
172+
flatListRef.current?.scrollToIndex({
173+
index: params?.index ?? 0,
174+
animated: params?.animated ?? false,
175+
viewPosition: params?.viewPosition ?? 0.5,
176+
});
177+
}, params?.timeout ?? 0);
178+
});
179+
180+
const scrollToMessage = useFreshCallback<MessageListContextValue['scrollToMessage']>((messageId, options) => {
181+
if (!flatListRef.current) {
182+
logFlatListRefWarning();
183+
return false;
184+
}
185+
186+
const foundMessageIndex = messages.findIndex((it) => it.messageId === messageId);
187+
const isIncludedInList = foundMessageIndex > -1;
188+
189+
if (isIncludedInList) {
190+
if (options?.focusAnimated) {
191+
setTimeout(
192+
() => onUpdateSearchItem({ startingPoint: messages[foundMessageIndex].createdAt }),
193+
MESSAGE_FOCUS_ANIMATION_DELAY,
194+
);
195+
}
196+
lazyScrollToIndex({
197+
index: foundMessageIndex,
198+
animated: true,
199+
timeout: 0,
200+
viewPosition: options?.viewPosition,
201+
});
202+
return true;
203+
} else {
204+
return false;
205+
}
206+
});
207+
208+
return {
209+
flatListRef,
210+
lazyScrollToIndex,
211+
lazyScrollToBottom,
212+
scrollToMessage,
213+
};
214+
};
215+
216+
const logFlatListRefWarning = () => {
217+
Logger.warn(
218+
'Cannot find flatListRef.current, please render FlatList and pass the flatListRef' +
219+
'or please try again after FlatList has been rendered.',
220+
);
221+
};

packages/uikit-react-native/src/domain/groupChannel/types.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type React from 'react';
2+
import type { FlatList } from 'react-native';
23

34
import type { UseGroupChannelMessagesOptions } from '@sendbird/uikit-chat-hooks';
45
import type {
@@ -95,6 +96,10 @@ export interface GroupChannelProps {
9596
enableTypingIndicator: boolean;
9697
keyboardAvoidOffset?: number;
9798
groupChannelPubSub: PubSub<GroupChannelPubSubContextPayload>;
99+
100+
messages: SendbirdMessage[];
101+
// Changing the search item will trigger the focus animation on messages.
102+
onUpdateSearchItem: (searchItem?: GroupChannelProps['MessageList']['searchItem']) => void;
98103
};
99104
}
100105

@@ -117,6 +122,42 @@ export interface GroupChannelContextsType {
117122
typingUsers: SendbirdUser[];
118123
}>;
119124
PubSub: React.Context<PubSub<GroupChannelPubSubContextPayload>>;
125+
MessageList: React.Context<{
126+
/**
127+
* ref object for FlatList of MessageList
128+
* */
129+
flatListRef: React.MutableRefObject<FlatList | null>;
130+
/**
131+
* Function that scrolls to a message within a group channel.
132+
* @param messageId {number} - The id of the message to scroll.
133+
* @param options {object} - Scroll options (optional).
134+
* @param options.focusAnimated {boolean} - Enable a shake animation on the message component upon completion of scrolling.
135+
* @param options.viewPosition {number} - Position information to adjust the visible area during scrolling. bottom(0) ~ top(1.0)
136+
*
137+
* @example
138+
* ```
139+
* const { scrollToMessage } = useContext(GroupChannelContexts.MessageList);
140+
* const messageIncludedInMessageList = scrollToMessage(lastMessage.messageId, { focusAnimated: true, viewPosition: 1 });
141+
* if (!messageIncludedInMessageList) console.warn('Message not found in the message list.');
142+
* ```
143+
* */
144+
scrollToMessage: (messageId: number, options?: { focusAnimated?: boolean; viewPosition?: number }) => boolean;
145+
/**
146+
* Call the FlatList function asynchronously to scroll to bottom lazily
147+
* to avoid scrolling before data rendering has been committed.
148+
* */
149+
lazyScrollToBottom: (params?: { animated?: boolean; timeout?: number }) => void;
150+
/**
151+
* Call the FlatList function asynchronously to scroll to index lazily.
152+
* to avoid scrolling before data rendering has been committed.
153+
* */
154+
lazyScrollToIndex: (params?: {
155+
index?: number;
156+
animated?: boolean;
157+
timeout?: number;
158+
viewPosition?: number;
159+
}) => void;
160+
}>;
120161
}
121162
export interface GroupChannelModule {
122163
Provider: CommonComponent<GroupChannelProps['Provider']>;

packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ const createGroupChannelFragment = (initModule?: Partial<GroupChannelModule>): G
171171
groupChannelPubSub={groupChannelPubSub}
172172
enableTypingIndicator={enableTypingIndicator ?? sbOptions.uikit.groupChannel.channel.enableTypingIndicator}
173173
keyboardAvoidOffset={keyboardAvoidOffset}
174+
messages={messages}
175+
onUpdateSearchItem={onUpdateSearchItem}
174176
>
175177
<GroupChannelModule.Header
176178
shouldHideRight={navigateFromMessageSearch}

packages/uikit-utils/src/hooks/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ export const useIsFirstMount = () => {
7272
return isFirstMount.current;
7373
};
7474

75-
export const useFreshCallback = <T extends (...args: any[]) => any>(callback: T): T => {
75+
export const useFreshCallback = <T extends Function>(callback: T): T => {
7676
const ref = useRef<T>(callback);
7777
ref.current = callback;
78-
return useCallback(((...args) => ref.current(...args)) as T, []);
78+
return useCallback(((...args: any[]) => ref.current(...args)) as unknown as T, []);
7979
};
8080

8181
export const useDebounceEffect = (action: () => void, delay: number, deps: DependencyList = []) => {

0 commit comments

Comments
 (0)