diff --git a/package.json b/package.json index 8a37c04..0979620 100644 --- a/package.json +++ b/package.json @@ -58,8 +58,8 @@ "@react-native-community/eslint-config": "^3.0.2", "@release-it/conventional-changelog": "^5.0.0", "@types/jest": "^28.1.2", - "@types/react": "~17.0.21", - "@types/react-native": "0.70.0", + "@types/react": "18.2.0", + "@types/react-native": "0.71.7", "commitlint": "^17.0.2", "del-cli": "^5.0.0", "eslint": "^8.4.1", diff --git a/src/CuteChat.tsx b/src/CuteChat.tsx index 398fbcd..e50007c 100644 --- a/src/CuteChat.tsx +++ b/src/CuteChat.tsx @@ -7,20 +7,31 @@ import React, { useEffect, useLayoutEffect, useMemo, + useRef, useState, } from 'react'; -import { Alert, NativeScrollEvent } from 'react-native'; +import { + Alert, + FlatList, + NativeScrollEvent, + StyleProp, + ViewStyle, +} from 'react-native'; import type { IMessage } from 'react-native-gifted-chat'; import { GiftedChat, GiftedChatProps } from 'react-native-gifted-chat'; import { appendSnapshot } from './utils/appendSnapshot'; import { prepareSnapshot } from './utils/prepareSnapshot'; import { isCloseToBottom } from './utils/isCloseToBottom'; +import { isCloseToTop } from './utils/isCloseToTop'; +import { ChatFooter } from './components/ChatFooter/ChatFooter'; interface CustomCuteChatProps { chatId: string; user: User; onSend?: (newMessages: IMessage[]) => void; setIsLoading?: (isLoading: boolean) => void; + newMessagesBannerComponent?: () => React.ReactNode; + newMessagesBannerStyles?: StyleProp; } interface User { @@ -38,15 +49,19 @@ const messageBatch = 20; export function CuteChat(props: CuteChatProps) { const { chatId, user, setIsLoading } = props; + const [closeToTop, setCloseToTop] = useState(true); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [initializing, setInitializing] = useState(true); const [lastMessageDoc, setLastMessageDoc] = useState(null); + const [hasNewMessages, setHasNewMessages] = useState(false); const memoizedUser = useMemo(() => ({ _id: user.id, ...user }), [user]); const startDate = useMemo(() => new Date(), []); + const chatListRef = useRef>(null); + const setIsLoadingBool = useCallback( (isLoading: boolean) => { setIsLoading?.(isLoading); @@ -139,6 +154,8 @@ export function CuteChat(props: CuteChatProps) { console.log('New messages'); const snapshotChanges = await prepareSnapshot(snapshot, chatId); setMessages((old) => appendSnapshot(old, snapshotChanges)); + + setHasNewMessages(true); } }, (error: Error) => console.error('Error fetching documents: ', error) @@ -281,16 +298,37 @@ export function CuteChat(props: CuteChatProps) { console.log('Amount of msgs:', messages.length); + console.log('Close to top:', closeToTop); + return ( ( + <> + setHasNewMessages(false)} + closeToTop={closeToTop} + chatRef={chatListRef} + /> + {props.renderChatFooter?.()} + + )} messages={messages} onSend={props.onSend || onSend} user={memoizedUser} inverted={true} listViewProps={{ + ref: chatListRef, onScroll: ({ nativeEvent }: { nativeEvent: NativeScrollEvent }) => { if (isCloseToBottom(nativeEvent)) fetchMoreMessages(); + + if (isCloseToTop(nativeEvent)) setCloseToTop(true); + else setCloseToTop(false); }, scrollEventThrottle: 500, }} diff --git a/src/components/ChatFooter/ChatFooter.tsx b/src/components/ChatFooter/ChatFooter.tsx new file mode 100644 index 0000000..774c3a4 --- /dev/null +++ b/src/components/ChatFooter/ChatFooter.tsx @@ -0,0 +1,60 @@ +import React, { ReactNode, RefObject } from 'react'; +import { FlatList, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { IMessage } from 'react-native-gifted-chat'; +import { RightSection } from './RightSection'; +import { MiddleSection } from './MiddleSection'; +import { LeftSection } from './LeftSection'; + +export const ChatFooter = (props: { + newMessagesBannerComponent?: () => ReactNode; + newMessagesBannerStyles?: StyleProp; + scrollToBottomComponent?: () => ReactNode; + scrollToBottomStyle?: StyleProp; + + closeToTop: boolean; + hasNewMessages: boolean; + markNewMessagesAsSeen: () => void; + chatRef: RefObject>; +}) => { + const scrollToBottom = () => { + props.chatRef.current?.scrollToOffset({ offset: 0, animated: true }); + }; + + const scrollDownAndMarkAsRead = () => { + scrollToBottom(); + props.markNewMessagesAsSeen(); + }; + + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + display: 'flex', + position: 'absolute', + flexDirection: 'row', + justifyContent: 'flex-start', + width: '100%', + bottom: 40, + left: 0, + }, +}); diff --git a/src/components/ChatFooter/LeftSection.tsx b/src/components/ChatFooter/LeftSection.tsx new file mode 100644 index 0000000..f2c7f0b --- /dev/null +++ b/src/components/ChatFooter/LeftSection.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +export const LeftSection = () => { + return ; +}; + +const styles = StyleSheet.create({ + container: { display: 'flex', flex: 1 }, +}); diff --git a/src/components/ChatFooter/MiddleSection.tsx b/src/components/ChatFooter/MiddleSection.tsx new file mode 100644 index 0000000..5621191 --- /dev/null +++ b/src/components/ChatFooter/MiddleSection.tsx @@ -0,0 +1,49 @@ +import React, { ReactNode } from 'react'; +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native'; + +type Props = { + newMessagesBannerComponent?: () => ReactNode; + newMessagesBannerStyles?: StyleProp; + onNewMessagesBannerPress?: () => void; + + closeToTop: boolean; + hasNewMessages: boolean; +}; + +export const MiddleSection = (props: Props) => { + const shouldDisplayNewMessagesBanner = + !props.closeToTop && props.hasNewMessages; + + return ( + + {shouldDisplayNewMessagesBanner && props.newMessagesBannerComponent && ( + + {props.newMessagesBannerComponent()} + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { display: 'flex', flex: 1 }, + newMessagesBanner: { + alignSelf: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.9)', + padding: 10, + borderRadius: 100, + shadowColor: 'rgba(0, 0, 0, 1)', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.2, + shadowRadius: 3, + }, +}); diff --git a/src/components/ChatFooter/RightSection.tsx b/src/components/ChatFooter/RightSection.tsx new file mode 100644 index 0000000..e6668fd --- /dev/null +++ b/src/components/ChatFooter/RightSection.tsx @@ -0,0 +1,44 @@ +import React, { ReactNode } from 'react'; +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native'; + +type Props = { + scrollToBottomComponent?: () => ReactNode; + scrollToBottomStyle?: StyleProp; + scrollToBottom?: () => void; + closeToTop: boolean; +}; + +export const RightSection = (props: Props) => { + return ( + + {!props.closeToTop && props.scrollToBottomComponent && ( + + {props.scrollToBottomComponent()} + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { display: 'flex', flex: 1 }, + scrollToBottom: { + alignSelf: 'flex-end', + backgroundColor: 'rgba(255, 255, 255, 0.9)', + padding: 10, + borderRadius: 100, + shadowColor: 'rgba(0, 0, 0, 1)', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.2, + shadowRadius: 3, + }, +}); diff --git a/src/utils/isCloseToTop.ts b/src/utils/isCloseToTop.ts new file mode 100644 index 0000000..9d851d2 --- /dev/null +++ b/src/utils/isCloseToTop.ts @@ -0,0 +1,7 @@ +import { NativeScrollEvent } from 'react-native'; + +export function isCloseToTop({ contentOffset }: NativeScrollEvent) { + const paddingToTop = 2_000; + + return contentOffset.y <= paddingToTop; +} diff --git a/yarn.lock b/yarn.lock index 56de640..bcd9b51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2377,10 +2377,10 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== -"@types/react-native@0.70.0": - version "0.70.0" - resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.70.0.tgz#f8cdcdd542d36467d7591585b93d27e0563676e0" - integrity sha512-yBN7qJDfs0Vwr34NyfW1SWzalHQoYtpUWf0t4UJY9C5ft58BRr46+r92I0v+l3QX4VNsSRMHVAAWqLLCbIkM+g== +"@types/react-native@0.71.7": + version "0.71.7" + resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.71.7.tgz#b6ce1c512097fc2047bacd01cc18ce2b8ca2920a" + integrity sha512-SYlf5Vw2qX4qDQfdcCEdfskoMG6yn0T/jWwvBNR1xZJ3qEobcGNHuOTZQdfu4TNK+VFkH5rSXZraRlio1/bvcA== dependencies: "@types/react" "*" @@ -2393,10 +2393,10 @@ "@types/scheduler" "*" csstype "^3.0.2" -"@types/react@~17.0.21": - version "17.0.62" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.62.tgz#2efe8ddf8533500ec44b1334dd1a97caa2f860e3" - integrity sha512-eANCyz9DG8p/Vdhr0ZKST8JV12PhH2ACCDYlFw6DIO+D+ca+uP4jtEDEpVqXZrh/uZdXQGwk7whJa3ah5DtyLw== +"@types/react@18.2.0": + version "18.2.0" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.0.tgz#15cda145354accfc09a18d2f2305f9fc099ada21" + integrity sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA== dependencies: "@types/prop-types" "*" "@types/scheduler" "*"