diff --git a/apps/testing/src/pages/PlaygroundPage.tsx b/apps/testing/src/pages/PlaygroundPage.tsx index 13b7c1e31..b8df058f7 100644 --- a/apps/testing/src/pages/PlaygroundPage.tsx +++ b/apps/testing/src/pages/PlaygroundPage.tsx @@ -10,5 +10,6 @@ export function PlaygroundPage() { replyType: 'thread', } }} + // autoscrollMessageOverflowToTop={true} />; } diff --git a/src/lib/Sendbird/context/SendbirdProvider.tsx b/src/lib/Sendbird/context/SendbirdProvider.tsx index 1a3ecced5..4dcecaffb 100644 --- a/src/lib/Sendbird/context/SendbirdProvider.tsx +++ b/src/lib/Sendbird/context/SendbirdProvider.tsx @@ -72,6 +72,7 @@ const SendbirdContextManager = ({ sdkInitParams, customExtensionParams, isMultipleFilesMessageEnabled = false, + autoscrollMessageOverflowToTop = false, eventHandlers, htmlTextDirection = 'ltr', forceLeftToRightMessageLayout = false, @@ -288,6 +289,7 @@ const SendbirdContextManager = ({ setCurrentTheme, setCurrenttheme: setCurrentTheme, // deprecated: typo isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop, uikitMultipleFilesMessageLimit, logger, pubSub, @@ -316,6 +318,7 @@ const SendbirdContextManager = ({ currentTheme, setCurrentTheme, isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop, uikitMultipleFilesMessageLimit, logger, pubSub, @@ -395,6 +398,7 @@ const InternalSendbirdProvider = (props: SendbirdProviderProps & { logger: Logge }, disableMarkAsDelivered: props?.disableMarkAsDelivered, isMultipleFilesMessageEnabled: props?.isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop: props?.autoscrollMessageOverflowToTop, }, eventHandlers: props?.eventHandlers, }); diff --git a/src/lib/Sendbird/context/initialState.ts b/src/lib/Sendbird/context/initialState.ts index 0a4d3b953..0e1f662d2 100644 --- a/src/lib/Sendbird/context/initialState.ts +++ b/src/lib/Sendbird/context/initialState.ts @@ -33,6 +33,7 @@ const config: SendbirdStateConfig = { forceLeftToRightMessageLayout: false, disableMarkAsDelivered: false, isMultipleFilesMessageEnabled: false, + autoscrollMessageOverflowToTop: false, htmlTextDirection: 'ltr', uikitUploadSizeLimit: DEFAULT_UPLOAD_SIZE_LIMIT, uikitMultipleFilesMessageLimit: DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, diff --git a/src/lib/Sendbird/types.ts b/src/lib/Sendbird/types.ts index 729891eb1..1dc663f00 100644 --- a/src/lib/Sendbird/types.ts +++ b/src/lib/Sendbird/types.ts @@ -218,6 +218,7 @@ export interface SendbirdProviderProps extends CommonUIKitConfigProps, React.Pro sdkInitParams?: SendbirdChatInitParams; customExtensionParams?: CustomExtensionParams; isMultipleFilesMessageEnabled?: boolean; + autoscrollMessageOverflowToTop?: boolean; // UserProfile renderUserProfile?: (props: RenderUserProfileProps) => React.ReactElement; onStartDirectMessage?: (channel: GroupChannel) => void; @@ -260,6 +261,7 @@ export interface SendbirdStateConfig { markAsDeliveredScheduler: MarkAsDeliveredSchedulerType; disableMarkAsDelivered: boolean; isMultipleFilesMessageEnabled: boolean; + autoscrollMessageOverflowToTop: boolean; // Remote configs set from dashboard by UIKit feature configuration common: { enableUsingDefaultUserProfile: SBUConfig['common']['enableUsingDefaultUserProfile']; diff --git a/src/modules/App/AppLayout.tsx b/src/modules/App/AppLayout.tsx index c0d160011..601362500 100644 --- a/src/modules/App/AppLayout.tsx +++ b/src/modules/App/AppLayout.tsx @@ -13,6 +13,7 @@ import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export const AppLayout = (props: AppLayoutProps) => { const { isMessageGroupingEnabled, + autoscrollMessageOverflowToTop, allowProfileEdit, onProfileEditSuccess, disableAutoSelect, @@ -50,6 +51,7 @@ export const AppLayout = (props: AppLayoutProps) => { showSearchIcon={showSearchIcon} isReactionEnabled={isReactionEnabled} isMessageGroupingEnabled={isMessageGroupingEnabled} + autoscrollMessageOverflowToTop={autoscrollMessageOverflowToTop} allowProfileEdit={allowProfileEdit} onProfileEditSuccess={onProfileEditSuccess} currentChannel={currentChannel} @@ -69,6 +71,7 @@ export const AppLayout = (props: AppLayoutProps) => { isReactionEnabled={isReactionEnabled} showSearchIcon={showSearchIcon} isMessageGroupingEnabled={isMessageGroupingEnabled} + autoscrollMessageOverflowToTop={autoscrollMessageOverflowToTop} allowProfileEdit={allowProfileEdit} onProfileEditSuccess={onProfileEditSuccess} disableAutoSelect={disableAutoSelect} diff --git a/src/modules/App/DesktopLayout.tsx b/src/modules/App/DesktopLayout.tsx index d68116ce0..c63d6b15d 100644 --- a/src/modules/App/DesktopLayout.tsx +++ b/src/modules/App/DesktopLayout.tsx @@ -21,6 +21,7 @@ export const DesktopLayout: React.FC = (props: DesktopLayout replyType, isMessageGroupingEnabled, isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop, allowProfileEdit, showSearchIcon, onProfileEditSuccess, @@ -104,6 +105,7 @@ export const DesktopLayout: React.FC = (props: DesktopLayout replyType: replyType, isMessageGroupingEnabled: isMessageGroupingEnabled, isMultipleFilesMessageEnabled: isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop: autoscrollMessageOverflowToTop, // for GroupChannel animatedMessageId: highlightedMessage, onReplyInThreadClick: onClickThreadReply, diff --git a/src/modules/App/MobileLayout.tsx b/src/modules/App/MobileLayout.tsx index c1766fe8c..2a618929b 100644 --- a/src/modules/App/MobileLayout.tsx +++ b/src/modules/App/MobileLayout.tsx @@ -32,6 +32,7 @@ export const MobileLayout: React.FC = (props: MobileLayoutPro replyType, isMessageGroupingEnabled, isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop, allowProfileEdit, isReactionEnabled, showSearchIcon, @@ -155,6 +156,7 @@ export const MobileLayout: React.FC = (props: MobileLayoutPro replyType, isMessageGroupingEnabled, isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop, // for GroupChannel animatedMessageId: highlightedMessage, onReplyInThreadClick: ({ message }) => { diff --git a/src/modules/App/index.tsx b/src/modules/App/index.tsx index b9d67cfcf..188c0247d 100644 --- a/src/modules/App/index.tsx +++ b/src/modules/App/index.tsx @@ -29,6 +29,7 @@ export interface AppProps { config?: SendbirdProviderProps['config']; voiceRecord?: SendbirdProviderProps['voiceRecord']; isMultipleFilesMessageEnabled?: SendbirdProviderProps['isMultipleFilesMessageEnabled']; + autoscrollMessageOverflowToTop?: SendbirdProviderProps['autoscrollMessageOverflowToTop']; colorSet?: SendbirdProviderProps['colorSet']; stringSet?: SendbirdProviderProps['stringSet']; allowProfileEdit?: SendbirdProviderProps['allowProfileEdit']; @@ -99,6 +100,7 @@ export default function App(props: AppProps) { customExtensionParams, eventHandlers, isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop = false, isUserIdUsedForNickname = true, enableLegacyChannelModules = false, uikitOptions, @@ -138,6 +140,7 @@ export default function App(props: AppProps) { renderUserProfile={renderUserProfile} imageCompression={imageCompression} isMultipleFilesMessageEnabled={isMultipleFilesMessageEnabled} + autoscrollMessageOverflowToTop={autoscrollMessageOverflowToTop} voiceRecord={voiceRecord} onStartDirectMessage={(channel) => { setCurrentChannel(channel); @@ -159,6 +162,7 @@ export default function App(props: AppProps) { forceLeftToRightMessageLayout={forceLeftToRightMessageLayout} > void; + /** + * A function that forces scroll to message when new message is received. + */ + scrollMessageOverflowToTop?: (ref: React.MutableRefObject, message: CoreMessageType) => void; /** * @deprecated Please use `children` instead * @description Customizes all child components of the message. @@ -97,6 +101,10 @@ export interface MessageViewProps extends MessageProps { animatedMessageId: number | null; setAnimatedMessageId: React.Dispatch>; + + newMessageIds?: number[] | null; + setNewMessageIds?: (ids: number[]) => void; + onMessageAnimated?: () => void; /** @deprecated * */ highLightedMessageId?: number | null; @@ -119,6 +127,7 @@ const MessageView = (props: MessageViewProps) => { chainBottom, handleScroll, onNewMessageSeparatorVisibilityChange, + scrollMessageOverflowToTop, // MessageViewProps channel, @@ -147,6 +156,9 @@ const MessageView = (props: MessageViewProps) => { animatedMessageId, onMessageAnimated, usedInLegacy = true, + + newMessageIds, + setNewMessageIds, } = props; const { @@ -257,6 +269,23 @@ const MessageView = (props: MessageViewProps) => { }; }, [animatedMessageId, messageScrollRef.current, message.messageId]); + useLayoutEffect(() => { + if (newMessageIds?.length > 0 && newMessageIds.includes(message.messageId)) { + let rafId: number | null = null; + + rafId = requestAnimationFrame(() => { + scrollMessageOverflowToTop(messageScrollRef, message); + setNewMessageIds([]); + }); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + } + }, [newMessageIds]); + const renderedCustomSeparator = useMemo(() => renderCustomSeparator?.({ message }) ?? null, [message, renderCustomSeparator]); const renderChildren = () => { diff --git a/src/modules/GroupChannel/components/Message/index.tsx b/src/modules/GroupChannel/components/Message/index.tsx index a725365f6..b0727e0f1 100644 --- a/src/modules/GroupChannel/components/Message/index.tsx +++ b/src/modules/GroupChannel/components/Message/index.tsx @@ -30,6 +30,7 @@ export const Message = (props: MessageProps): React.ReactElement => { onBeforeDownloadFileMessage, messages, markAsUnread, + newMessageIds, }, actions: { toggleReaction, @@ -40,6 +41,7 @@ export const Message = (props: MessageProps): React.ReactElement => { sendUserMessage, resendMessage, deleteMessage, + setNewMessageIds, }, } = useGroupChannel(); @@ -94,6 +96,8 @@ export const Message = (props: MessageProps): React.ReactElement => { renderRemoveMessageModal={(props) => } usedInLegacy={false} onBeforeDownloadFileMessage={onBeforeDownloadFileMessage} + newMessageIds={newMessageIds} + setNewMessageIds={setNewMessageIds} /> ); }; diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index 519ceaf7e..87e5dcd44 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -89,12 +89,14 @@ export const MessageList = (props: GroupChannelMessageListProps) => { scrollDistanceFromBottomRef, markAsUnreadSourceRef, readState, + autoscrollMessageOverflowToTop, }, actions: { scrollToBottom, setIsScrollBottomReached, markAsReadAll, markAsUnread, + scrollToMessage, }, } = useGroupChannel(); @@ -204,6 +206,24 @@ export const MessageList = (props: GroupChannelMessageListProps) => { } }; + /** + * Force scroll to message + * when new message is received + * and the message content height is over the current scroll height + */ + const scrollMessageOverflowToTop = (ref: React.MutableRefObject, message: CoreMessageType) => { + if (!autoscrollMessageOverflowToTop) return; + const messageComponent = ref.current; + const messageComponentHeight = messageComponent?.clientHeight; + const currentScrollHeight = scrollRef.current?.offsetHeight; + + if (messageComponentHeight > currentScrollHeight) { + scrollToMessage(message.createdAt, message.messageId); + } else if (isScrollBottomReached) { + scrollToBottom(); + } + }; + const renderer = { frozenNotification() { if (!currentChannel || !currentChannel.isFrozen) return null; @@ -331,6 +351,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { renderSuggestedReplies, renderCustomSeparator, onNewMessageSeparatorVisibilityChange: checkDisplayedNewMessageSeparator, + scrollMessageOverflowToTop, })} ); diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index 82c884683..ca01e1f5f 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -56,6 +56,7 @@ const initialState = () => ({ isReactionEnabled: false, isMessageGroupingEnabled: true, isMultipleFilesMessageEnabled: false, + autoscrollMessageOverflowToTop: false, showSearchIcon: true, replyType: 'NONE', threadReplySelectType: ThreadReplySelectType.PARENT, @@ -83,6 +84,7 @@ export const InternalGroupChannelProvider = (props: GroupChannelProviderProps) = isReactionEnabled: props?.isReactionEnabled, isMessageGroupingEnabled: props?.isMessageGroupingEnabled, isMultipleFilesMessageEnabled: props?.isMultipleFilesMessageEnabled, + autoscrollMessageOverflowToTop: props?.autoscrollMessageOverflowToTop, showSearchIcon: props?.showSearchIcon, threadReplySelectType: props?.threadReplySelectType, disableMarkAsRead: props?.disableMarkAsRead, @@ -125,6 +127,7 @@ const GroupChannelManager :React.FC actions.scrollToBottom(true), 10); + if (!autoscrollMessageOverflowToTop) { + setTimeout(async () => actions.scrollToBottom(true), 10); + } else { + actions.setNewMessageIds(messages.map(it => it.messageId)); + } } }, onChannelDeleted: () => { @@ -271,6 +278,7 @@ const GroupChannelManager :React.FC { + // send message if (data.channel.url === state.currentChannel?.url) { actions.scrollToBottom(true); } @@ -339,6 +347,7 @@ const GroupChannelManager :React.FC Promise; updateUserMessage: (messageId: number, params: UserMessageUpdateParams) => Promise; + setNewMessageIds: (ids: number[]) => void; + // UI actions setQuoteMessage: (message: SendableMessageType | null) => void; setAnimatedMessageId: (messageId: number | null) => void; @@ -205,6 +207,10 @@ export const useGroupChannel = () => { store.setState(state => ({ ...state, firstUnreadMessageId: messageId })); }, []); + const setNewMessageIds = useCallback((newMessageIds: number[]) => { + store.setState(state => ({ ...state, newMessageIds })); + }, []); + const actions: GroupChannelActions = useMemo(() => { return { setCurrentChannel, @@ -213,6 +219,7 @@ export const useGroupChannel = () => { markAsUnread: state.markAsUnread, setReadStateChanged, setFirstUnreadMessageId, + setNewMessageIds, setQuoteMessage, scrollToBottom, scrollToMessage, diff --git a/src/modules/GroupChannel/context/types.ts b/src/modules/GroupChannel/context/types.ts index 3c647e719..159df38a8 100644 --- a/src/modules/GroupChannel/context/types.ts +++ b/src/modules/GroupChannel/context/types.ts @@ -47,6 +47,7 @@ interface InternalGroupChannelState extends MessageDataSource { animatedMessageId: number | null; isScrollBottomReached: boolean; readState: string | null; + newMessageIds: number[] | null; // References - will be managed together scrollRef: React.RefObject; @@ -58,6 +59,7 @@ interface InternalGroupChannelState extends MessageDataSource { isReactionEnabled: boolean; isMessageGroupingEnabled: boolean; isMultipleFilesMessageEnabled: boolean; + autoscrollMessageOverflowToTop: boolean; showSearchIcon: boolean; replyType: ReplyType; threadReplySelectType: ThreadReplySelectType; @@ -86,6 +88,7 @@ export interface GroupChannelProviderProps extends PropsWithChildren< isReactionEnabled?: boolean; isMessageGroupingEnabled?: boolean; isMultipleFilesMessageEnabled?: boolean; + autoscrollMessageOverflowToTop?: boolean; showSearchIcon?: boolean; replyType?: ReplyType; threadReplySelectType?: ThreadReplySelectType;