Skip to content

Commit

Permalink
Merge pull request #141 from sendbird/feat/scroll-to-message-with-id
Browse files Browse the repository at this point in the history
feat(UIKIT-4566): add scrollToMessage to group channel contexts
  • Loading branch information
bang9 authored Nov 3, 2023
2 parents b43552d + 4a6efdc commit 4da1ff0
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 51 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { useContext, useEffect, useRef } from 'react';
import type { FlatList } from 'react-native';
import React, { useContext, useEffect } from 'react';

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

const id = useUniqHandlerId('GroupChannelMessageList');
const ref = useRef<FlatList<SendbirdMessage>>(null);
const isFirstMount = useIsFirstMount();

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

// FIXME: Workaround, should run after data has been applied to UI.
const lazyScrollToIndex = (index = 0, animated = false, timeout = 0) => {
setTimeout(() => {
ref.current?.scrollToIndex({ index, animated, viewPosition: 0.5 });
}, timeout);
};

const scrollToMessage = useFreshCallback((createdAt: number, focusAnimated = false): boolean => {
const foundMessageIndex = props.messages.findIndex((it) => it.createdAt === createdAt);
const isIncludedInList = foundMessageIndex > -1;

if (isIncludedInList) {
if (focusAnimated) {
setTimeout(() => props.onUpdateSearchItem({ startingPoint: createdAt }), MESSAGE_FOCUS_ANIMATION_DELAY);
}
lazyScrollToIndex(foundMessageIndex, true, isFirstMount ? MESSAGE_SEARCH_SAFE_SCROLL_DELAY : 0);
} else {
if (props.channel.messageOffsetTimestamp <= createdAt) {
if (focusAnimated) props.onUpdateSearchItem({ startingPoint: createdAt });
props.onResetMessageListWithStartingPoint(createdAt);
if (isIncludedInList) {
if (focusAnimated) {
setTimeout(() => props.onUpdateSearchItem({ startingPoint: createdAt }), MESSAGE_FOCUS_ANIMATION_DELAY);
}
lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout });
} else {
return false;
if (props.channel.messageOffsetTimestamp <= createdAt) {
if (focusAnimated) props.onUpdateSearchItem({ startingPoint: createdAt });
props.onResetMessageListWithStartingPoint(createdAt);
} else {
return false;
}
}
}

return true;
});
return true;
},
);

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

props.onResetMessageList(() => {
props.onScrolledAwayFromBottom(false);
lazyScrollToBottom(animated);
lazyScrollToBottom({ animated });
});
} else {
lazyScrollToBottom(animated);
lazyScrollToBottom({ animated });
}
});

Expand All @@ -79,7 +65,7 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => {
const isRecentMessage = recentMessage && recentMessage.messageId === event.messageId;
const scrollReachedBottomAndCanScroll = !props.scrolledAwayFromBottom && !props.hasNext();
if (isRecentMessage && scrollReachedBottomAndCanScroll) {
lazyScrollToBottom(true, 250);
lazyScrollToBottom({ animated: true, timeout: 250 });
}
},
});
Expand All @@ -102,24 +88,24 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => {
});
}, [props.scrolledAwayFromBottom]);

// Only trigger once when message list mount with initial props.searchItem
// - Search screen + searchItem > mount message list
// - Reset message list + searchItem > re-mount message list
useEffect(() => {
// Only trigger once when message list mount with initial props.searchItem
// - Search screen + searchItem > mount message list
// - Reset message list + searchItem > re-mount message list
if (isFirstMount && props.searchItem) {
scrollToMessage(props.searchItem.startingPoint);
scrollToMessageWithCreatedAt(props.searchItem.startingPoint, false, MESSAGE_SEARCH_SAFE_SCROLL_DELAY);
}
}, [isFirstMount]);

const onPressParentMessage = useFreshCallback((message: SendbirdMessage) => {
const canScrollToParent = scrollToMessage(message.createdAt, true);
const canScrollToParent = scrollToMessageWithCreatedAt(message.createdAt, true, 0);
if (!canScrollToParent) toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error');
});

return (
<ChannelMessageList
{...props}
ref={ref}
ref={flatListRef}
onReplyMessage={setMessageToReply}
onEditMessage={setMessageToEdit}
onPressParentMessage={onPressParentMessage}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import React, { createContext, useCallback, useState } from 'react';
import React, { createContext, useCallback, useRef, useState } from 'react';
import type { FlatList } from 'react-native';

import { useChannelHandler } from '@sendbird/uikit-chat-hooks';
import {
ContextValue,
Logger,
NOOP,
SendbirdFileMessage,
SendbirdGroupChannel,
SendbirdMessage,
SendbirdUser,
SendbirdUserMessage,
isDifferentChannel,
useFreshCallback,
useUniqHandlerId,
} from '@sendbird/uikit-utils';

import ProviderLayout from '../../../components/ProviderLayout';
import { MESSAGE_FOCUS_ANIMATION_DELAY } from '../../../constants';
import { useLocalization, useSendbirdChat } from '../../../hooks/useContext';
import type { PubSub } from '../../../utils/pubsub';
import type { GroupChannelContextsType, GroupChannelModule, GroupChannelPubSubContextPayload } from '../types';
import { GroupChannelProps } from '../types';

export const GroupChannelContexts: GroupChannelContextsType = {
Fragment: createContext({
Expand All @@ -30,6 +37,16 @@ export const GroupChannelContexts: GroupChannelContextsType = {
publish: NOOP,
subscribe: () => NOOP,
} as PubSub<GroupChannelPubSubContextPayload>),
MessageList: createContext({
flatListRef: { current: null },
scrollToMessage: () => false,
lazyScrollToBottom: () => {
// noop
},
lazyScrollToIndex: () => {
// noop
},
} as MessageListContextValue),
};

export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({
Expand All @@ -38,6 +55,8 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({
enableTypingIndicator,
keyboardAvoidOffset = 0,
groupChannelPubSub,
messages,
onUpdateSearchItem,
}) => {
if (!channel) throw new Error('GroupChannel is not provided to GroupChannelModule');

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

const { flatListRef, lazyScrollToIndex, lazyScrollToBottom, scrollToMessage } = useScrollActions({
messages,
onUpdateSearchItem,
});

const updateInputMode = (mode: 'send' | 'edit' | 'reply', message?: SendbirdUserMessage | SendbirdFileMessage) => {
if (mode === 'send' || !message) {
setMessageToEdit(undefined);
Expand Down Expand Up @@ -101,12 +125,97 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({
setMessageToReply: useCallback((message) => updateInputMode('reply', message), []),
}}
>
<GroupChannelContexts.TypingIndicator.Provider value={{ typingUsers }}>
<GroupChannelContexts.PubSub.Provider value={groupChannelPubSub}>
{children}
</GroupChannelContexts.PubSub.Provider>
</GroupChannelContexts.TypingIndicator.Provider>
<GroupChannelContexts.PubSub.Provider value={groupChannelPubSub}>
<GroupChannelContexts.TypingIndicator.Provider value={{ typingUsers }}>
<GroupChannelContexts.MessageList.Provider
value={{
flatListRef,
scrollToMessage,
lazyScrollToIndex,
lazyScrollToBottom,
}}
>
{children}
</GroupChannelContexts.MessageList.Provider>
</GroupChannelContexts.TypingIndicator.Provider>
</GroupChannelContexts.PubSub.Provider>
</GroupChannelContexts.Fragment.Provider>
</ProviderLayout>
);
};

type MessageListContextValue = ContextValue<GroupChannelContextsType['MessageList']>;
const useScrollActions = (params: Pick<GroupChannelProps['Provider'], 'messages' | 'onUpdateSearchItem'>) => {
const { messages, onUpdateSearchItem } = params;
const flatListRef = useRef<FlatList<SendbirdMessage>>(null);

// FIXME: Workaround, should run after data has been applied to UI.
const lazyScrollToBottom = useFreshCallback<MessageListContextValue['lazyScrollToIndex']>((params) => {
if (!flatListRef.current) {
logFlatListRefWarning();
return;
}

setTimeout(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: params?.animated ?? false });
}, params?.timeout ?? 0);
});

// FIXME: Workaround, should run after data has been applied to UI.
const lazyScrollToIndex = useFreshCallback<MessageListContextValue['lazyScrollToIndex']>((params) => {
if (!flatListRef.current) {
logFlatListRefWarning();
return;
}

setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: params?.index ?? 0,
animated: params?.animated ?? false,
viewPosition: params?.viewPosition ?? 0.5,
});
}, params?.timeout ?? 0);
});

const scrollToMessage = useFreshCallback<MessageListContextValue['scrollToMessage']>((messageId, options) => {
if (!flatListRef.current) {
logFlatListRefWarning();
return false;
}

const foundMessageIndex = messages.findIndex((it) => it.messageId === messageId);
const isIncludedInList = foundMessageIndex > -1;

if (isIncludedInList) {
if (options?.focusAnimated) {
setTimeout(
() => onUpdateSearchItem({ startingPoint: messages[foundMessageIndex].createdAt }),
MESSAGE_FOCUS_ANIMATION_DELAY,
);
}
lazyScrollToIndex({
index: foundMessageIndex,
animated: true,
timeout: 0,
viewPosition: options?.viewPosition,
});
return true;
} else {
return false;
}
});

return {
flatListRef,
lazyScrollToIndex,
lazyScrollToBottom,
scrollToMessage,
};
};

const logFlatListRefWarning = () => {
Logger.warn(
'Cannot find flatListRef.current, please render FlatList and pass the flatListRef' +
'or please try again after FlatList has been rendered.',
);
};
41 changes: 41 additions & 0 deletions packages/uikit-react-native/src/domain/groupChannel/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type React from 'react';
import type { FlatList } from 'react-native';

import type { UseGroupChannelMessagesOptions } from '@sendbird/uikit-chat-hooks';
import type {
Expand Down Expand Up @@ -95,6 +96,10 @@ export interface GroupChannelProps {
enableTypingIndicator: boolean;
keyboardAvoidOffset?: number;
groupChannelPubSub: PubSub<GroupChannelPubSubContextPayload>;

messages: SendbirdMessage[];
// Changing the search item will trigger the focus animation on messages.
onUpdateSearchItem: (searchItem?: GroupChannelProps['MessageList']['searchItem']) => void;
};
}

Expand All @@ -117,6 +122,42 @@ export interface GroupChannelContextsType {
typingUsers: SendbirdUser[];
}>;
PubSub: React.Context<PubSub<GroupChannelPubSubContextPayload>>;
MessageList: React.Context<{
/**
* ref object for FlatList of MessageList
* */
flatListRef: React.MutableRefObject<FlatList | null>;
/**
* Function that scrolls to a message within a group channel.
* @param messageId {number} - The id of the message to scroll.
* @param options {object} - Scroll options (optional).
* @param options.focusAnimated {boolean} - Enable a shake animation on the message component upon completion of scrolling.
* @param options.viewPosition {number} - Position information to adjust the visible area during scrolling. bottom(0) ~ top(1.0)
*
* @example
* ```
* const { scrollToMessage } = useContext(GroupChannelContexts.MessageList);
* const messageIncludedInMessageList = scrollToMessage(lastMessage.messageId, { focusAnimated: true, viewPosition: 1 });
* if (!messageIncludedInMessageList) console.warn('Message not found in the message list.');
* ```
* */
scrollToMessage: (messageId: number, options?: { focusAnimated?: boolean; viewPosition?: number }) => boolean;
/**
* Call the FlatList function asynchronously to scroll to bottom lazily
* to avoid scrolling before data rendering has been committed.
* */
lazyScrollToBottom: (params?: { animated?: boolean; timeout?: number }) => void;
/**
* Call the FlatList function asynchronously to scroll to index lazily.
* to avoid scrolling before data rendering has been committed.
* */
lazyScrollToIndex: (params?: {
index?: number;
animated?: boolean;
timeout?: number;
viewPosition?: number;
}) => void;
}>;
}
export interface GroupChannelModule {
Provider: CommonComponent<GroupChannelProps['Provider']>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ const createGroupChannelFragment = (initModule?: Partial<GroupChannelModule>): G
groupChannelPubSub={groupChannelPubSub}
enableTypingIndicator={enableTypingIndicator ?? sbOptions.uikit.groupChannel.channel.enableTypingIndicator}
keyboardAvoidOffset={keyboardAvoidOffset}
messages={messages}
onUpdateSearchItem={onUpdateSearchItem}
>
<GroupChannelModule.Header
shouldHideRight={navigateFromMessageSearch}
Expand Down
4 changes: 2 additions & 2 deletions packages/uikit-utils/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ export const useIsFirstMount = () => {
return isFirstMount.current;
};

export const useFreshCallback = <T extends (...args: any[]) => any>(callback: T): T => {
export const useFreshCallback = <T extends Function>(callback: T): T => {
const ref = useRef<T>(callback);
ref.current = callback;
return useCallback(((...args) => ref.current(...args)) as T, []);
return useCallback(((...args: any[]) => ref.current(...args)) as unknown as T, []);
};

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

0 comments on commit 4da1ff0

Please sign in to comment.