diff --git a/example/package.json b/example/package.json index 1f18246..a8d02de 100644 --- a/example/package.json +++ b/example/package.json @@ -18,7 +18,8 @@ "react-native": "0.71.10", "react-native-image-picker": "^5.4.2", "react-native-svg": "^13.9.0", - "react-native-svg-transformer": "^1.0.0" + "react-native-svg-transformer": "^1.0.0", + "@qteab/react-native-firebase-chat": "file:../" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/example/yarn.lock b/example/yarn.lock index f235369..400d9d2 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -1188,6 +1188,14 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.42.0.tgz#484a1d638de2911e6f5a30c12f49c7e4a3270fb6" integrity sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw== +"@expo/react-native-action-sheet@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@expo/react-native-action-sheet/-/react-native-action-sheet-4.1.1.tgz#a94a11088b8f146ac86b3ae710deef609a922d82" + integrity sha512-4KRaba2vhqDRR7ObBj6nrD5uJw8ePoNHdIOMETTpgGTX7StUbrF4j/sfrP1YUyaPEa1P8FXdwG6pB+2WtrJd1A== + dependencies: + "@types/hoist-non-react-statics" "^3.3.1" + hoist-non-react-statics "^3.3.0" + "@hapi/hoek@^9.0.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -1529,6 +1537,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@qteab/react-native-firebase-chat@file:..": + version "0.5.4" + dependencies: + react-native-gifted-chat "^2.1.0" + "@react-native-community/cli-clean@^10.1.1": version "10.1.1" resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-10.1.1.tgz#4c73ce93a63a24d70c0089d4025daac8184ff504" @@ -1943,6 +1956,14 @@ dependencies: "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.1": + version "3.3.6" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz#6bba74383cdab98e8db4e20ce5b4a6b98caed010" + integrity sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -1975,6 +1996,18 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== +"@types/lodash.isequal@^4.5.8": + version "4.5.8" + resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz#b30bb6ff6a5f6c19b3daf389d649ac7f7a250499" + integrity sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.17.16" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.16.tgz#94ae78fab4a38d73086e962d0b65c30d816bfb0a" + integrity sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g== + "@types/node@*": version "20.3.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.1.tgz#e8a83f1aa8b649377bb1fb5d7bac5cb90e784dfe" @@ -2983,6 +3016,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== +dayjs@^1.11.13: + version "1.11.13" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" + integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== + dayjs@^1.8.15: version "1.11.8" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.8.tgz#4282f139c8c19dd6d0c7bd571e30c2d0ba7698ea" @@ -4066,6 +4104,13 @@ hermes-profile-transformer@^0.0.6: dependencies: source-map "^0.7.3" +hoist-non-react-statics@^3.3.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -5087,6 +5132,11 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -6169,7 +6219,7 @@ prompts@^2.0.1, prompts@^2.4.0: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@*, prop-types@^15.8.1: +prop-types@*, prop-types@^15.7.x, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -6219,7 +6269,7 @@ react-devtools-core@^4.26.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -react-is@^16.13.1: +react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -6239,6 +6289,25 @@ react-native-codegen@^0.71.5: jscodeshift "^0.13.1" nullthrows "^1.1.1" +react-native-communications@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-native-communications/-/react-native-communications-2.2.1.tgz#7883b56b20a002eeb790c113f8616ea8692ca795" + integrity sha512-5+C0X9mopI0+qxyQHzOPEi5v5rxNBQjxydPPiKMQSlX1RBIcJ8uTcqUPssQ9Mo8p6c1IKIWJUSqCj4jAmD0qVQ== + +react-native-gifted-chat@^2.1.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/react-native-gifted-chat/-/react-native-gifted-chat-2.8.1.tgz#92380aceb56024103de7c0003e3b43572c5a5641" + integrity sha512-x4Kq0YvmaHqQg/ENAmFzwcjJyH31cGrCWETFzUMmTZgWsXkkiJ1MamTnkLGQp6deVxM6G0QNxrF7IZnBOeMbsw== + dependencies: + "@expo/react-native-action-sheet" "^4.1.1" + "@types/lodash.isequal" "^4.5.8" + dayjs "^1.11.13" + lodash.isequal "^4.5.0" + react-native-communications "^2.2.1" + react-native-iphone-x-helper "^1.3.1" + react-native-lightbox-v2 "^0.9.2" + react-native-parsed-text "^0.0.22" + react-native-gradle-plugin@^0.71.19: version "0.71.19" resolved "https://registry.yarnpkg.com/react-native-gradle-plugin/-/react-native-gradle-plugin-0.71.19.tgz#3379e28341fcd189bc1f4691cefc84c1a4d7d232" @@ -6249,6 +6318,23 @@ react-native-image-picker@^5.4.2: resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-5.4.2.tgz#017d6b379cddf706acd7feb8222c2eb58e57eb8d" integrity sha512-C/k3cYAh8fBImoGEwmiChNwHx9fJGqAIu2E4BUJdI1XlL17tSYjfTDx/bsuF4amZwa7hxZdQnZmpk0EnwIEUaw== +react-native-iphone-x-helper@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz#20c603e9a0e765fd6f97396638bdeb0e5a60b010" + integrity sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg== + +react-native-lightbox-v2@^0.9.2: + version "0.9.2" + resolved "https://registry.yarnpkg.com/react-native-lightbox-v2/-/react-native-lightbox-v2-0.9.2.tgz#58e256ee18bdd2e270f228a911a76831251229e5" + integrity sha512-+8LwINeSWvPP69YAyWhiJQyaw4Gtu8X4EMvT3PEN615NrOeDaz8jOcIw73pzYJst3z4Z0vxvFB73iH16wCPXtw== + +react-native-parsed-text@^0.0.22: + version "0.0.22" + resolved "https://registry.yarnpkg.com/react-native-parsed-text/-/react-native-parsed-text-0.0.22.tgz#a23c756eaa5d6724296814755085127f9072e5f5" + integrity sha512-hfD83RDXZf9Fvth3DowR7j65fMnlqM9PpxZBGWkzVcUTFtqe6/yPcIoIAgrJbKn6YmtzkivmhWE2MCE4JKBXrQ== + dependencies: + prop-types "^15.7.x" + react-native-svg-transformer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/react-native-svg-transformer/-/react-native-svg-transformer-1.0.0.tgz#7a707e5e95d20321b5f3dcfd0c3c8762ebd0221b" diff --git a/src/CuteChat.tsx b/src/CuteChat.tsx index 8203bbc..398fbcd 100644 --- a/src/CuteChat.tsx +++ b/src/CuteChat.tsx @@ -2,10 +2,19 @@ import firestore, { FirebaseFirestoreTypes as FirebaseFirestore, firebase, } from '@react-native-firebase/firestore'; -import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; -import { Alert } from 'react-native'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from 'react'; +import { Alert, NativeScrollEvent } 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'; interface CustomCuteChatProps { chatId: string; @@ -24,84 +33,26 @@ interface User { type CuteChatProps = Omit & CustomCuteChatProps; +const messageBatch = 20; + export function CuteChat(props: CuteChatProps) { + const { chatId, user, setIsLoading } = props; + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [initializing, setInitializing] = useState(true); const [lastMessageDoc, setLastMessageDoc] = useState(null); - const { chatId, user } = props; - const memoizedUser = useMemo(() => ({ _id: user.id, ...user }), [user]); - const [initializing, setInitializing] = useState(true); - const setIsLoading = useMemo( - () => props.setIsLoading || (() => {}), - [props.setIsLoading] - ); - - // Utility function to convert a Firestore document to a Gifted Chat message - const docToMessage = useCallback( - async (doc: FirebaseFirestore.QueryDocumentSnapshot): Promise => { - const data = doc.data(); - - if (!data) { - throw new Error('Document data is undefined'); - } + const memoizedUser = useMemo(() => ({ _id: user.id, ...user }), [user]); + const startDate = useMemo(() => new Date(), []); - const [files, sender] = await Promise.all([ - new Promise< - | FirebaseFirestore.QueryDocumentSnapshot[] - | undefined - >((resolve, reject) => { - firestore() - .collection(`chats/${chatId}/messages/${doc.id}/files`) - .onSnapshot((snapshot) => { - if (snapshot.empty) { - resolve(undefined); - } else { - resolve(snapshot.docs); - } - }, reject); - }), - new Promise( - (resolve, reject) => { - firestore() - .doc(data.senderRef._documentPath._parts.join('/')) - .onSnapshot((snapshot) => { - if (!snapshot.exists) { - resolve(undefined); - } else { - resolve(snapshot.data()); - } - }, reject); - } - ), - ]); - - const image = files?.[0]?.data().url; - - // Fetch user data from reference - if (sender) { - return { - _id: doc.id, - createdAt: new Date(data.createdAt), - text: data.content, - user: { _id: data.senderId, ...sender }, - image: image, - readByIds: data.readByIds, - metadata: data.metadata, - }; - } else { - return { - _id: doc.id, - createdAt: new Date(data.createdAt), - text: data.content, - image: image, - system: true, - readByIds: data.readByIds, - metadata: data.metadata, - }; - } + const setIsLoadingBool = useCallback( + (isLoading: boolean) => { + setIsLoading?.(isLoading); + setLoading(isLoading); }, - [chatId] + [setIsLoading] ); const markMessagesAsRead = useCallback( @@ -147,48 +98,57 @@ export function CuteChat(props: CuteChatProps) { [chatId, memoizedUser._id] ); - // Fetch initial messages + // Fetch initial messages and subscribe to potential future messages useLayoutEffect(() => { - setIsLoading(true); + setIsLoadingBool(true); const messagesRef = firestore().collection(`chats/${chatId}/messages`); - const unsubscribe = messagesRef + const unsubscribeOldMessages = messagesRef .orderBy('createdAt', 'desc') - .limit(20) + .startAfter(startDate.toISOString()) + .limit(messageBatch) .onSnapshot( async (snapshot: FirebaseFirestore.QuerySnapshot) => { if (snapshot.empty) { setLastMessageDoc(null); setMessages([]); - setIsLoading(false); + setIsLoadingBool(false); setInitializing(false); markMessagesAsRead([]); } if (!snapshot.empty) { - setLastMessageDoc( - snapshot.docs[ - snapshot.docs.length - 1 - ] as FirebaseFirestore.QueryDocumentSnapshot - ); - - const newMessagesPromises = snapshot.docs.map(docToMessage); - const newMessages = await Promise.all(newMessagesPromises); + const snapshotChanges = await prepareSnapshot(snapshot, chatId); + setMessages((old) => appendSnapshot(old, snapshotChanges)); - setMessages(newMessages); - setIsLoading(false); + setIsLoadingBool(false); setInitializing(false); + } + }, + (error: Error) => console.error('Error fetching documents: ', error) + ); - markMessagesAsRead(newMessages); + const unsubscribeNewMessages = messagesRef + .orderBy('createdAt', 'asc') + .startAfter(startDate.toISOString()) + .onSnapshot( + async (snapshot: FirebaseFirestore.QuerySnapshot) => { + if (!snapshot.empty) { + console.log('New messages'); + const snapshotChanges = await prepareSnapshot(snapshot, chatId); + setMessages((old) => appendSnapshot(old, snapshotChanges)); } }, (error: Error) => console.error('Error fetching documents: ', error) ); - // Clean up function - return () => unsubscribe(); - }, [chatId, docToMessage, markMessagesAsRead, setIsLoading]); + + return () => { + unsubscribeOldMessages(); + unsubscribeNewMessages(); + }; + }, [chatId, markMessagesAsRead, setIsLoadingBool, startDate]); // Handle outgoing messages const onSend = async (newMessages: IMessage[] = []) => { @@ -250,55 +210,76 @@ export function CuteChat(props: CuteChatProps) { } }; + // Function to fetch more messages const fetchMoreMessages = useCallback(async () => { if (initializing) { - return; + return console.log( + 'Skipping fetching more messages since initializing is still true' + ); + } + + if (loading) { + return console.log( + 'Skipping fetching more messages since loading is already true' + ); } - setIsLoading(true); + setIsLoadingBool(true); try { + console.log('Fetching more messages...'); const messagesRef = firestore().collection(`chats/${chatId}/messages`); messagesRef .orderBy('createdAt', 'desc') .startAfter(lastMessageDoc) - .limit(20) + .limit(messageBatch) .onSnapshot(async (snapshot) => { + console.log('Messages received'); if (!snapshot.empty) { - setLastMessageDoc( - snapshot.docs[ - snapshot.docs.length - 1 - ] as FirebaseFirestore.QueryDocumentSnapshot - ); - - const newMessages = await Promise.all( - snapshot.docs.map(docToMessage) - ); - - setMessages((previousMessages) => { - const newMessagesFiltered = newMessages.filter( - (newMessage) => - !previousMessages.some( - (previousMessage) => previousMessage._id === newMessage._id - ) - ); - return GiftedChat.prepend(previousMessages, newMessagesFiltered); - }); - - markMessagesAsRead(newMessages); - setIsLoading(false); + const snapshotChanges = await prepareSnapshot(snapshot, chatId); + setMessages((old) => appendSnapshot(old, snapshotChanges)); + } else { + console.log('Snapshot empty'); } + + setIsLoadingBool(false); }); } catch (error) { console.error('Error fetching more messages: ', error); } - }, [ - chatId, - lastMessageDoc, - docToMessage, - markMessagesAsRead, - setIsLoading, - initializing, - ]); + }, [chatId, lastMessageDoc, setIsLoadingBool, initializing, loading]); + + // Keep `lastMessageDoc` up to date based on `messages` + useEffect(() => { + if (!messages.length) { + setLastMessageDoc(null); + return; + } + + try { + const lastMessage = messages[messages.length - 1]; + + if (!lastMessage) { + console.log('No last message. Skipping setting last message.'); + return; + } + + console.log('Last message: ', lastMessage); + const lastMessageRef = firestore().doc( + `chats/${chatId}/messages/${lastMessage._id}` + ); + + const unsubscribe = lastMessageRef.onSnapshot(async (snapshot) => { + setLastMessageDoc(snapshot); + }); + + return () => unsubscribe(); + } catch (error) { + console.error('Failed to set lastMessageDoc:', error); + return; + } + }, [messages, chatId]); + + console.log('Amount of msgs:', messages.length); return ( { + if (isCloseToBottom(nativeEvent)) fetchMoreMessages(); + }, + scrollEventThrottle: 500, }} /> ); diff --git a/src/docs/message-caching.md b/src/docs/message-caching.md new file mode 100644 index 0000000..e0044f6 --- /dev/null +++ b/src/docs/message-caching.md @@ -0,0 +1,9 @@ +### Message caching in CuteChat + +The subscriptions toward messages are setup the following way: + +- 1️⃣ One that fetches and subscribes to the latest 20 messages upon initialization, ignoring incoming messages, therefore staying static and only affected by deletion or modification of specified messages +- 2️⃣ One that fetches and subscribes to messages after a specified `messageDoc` (previous messages) +- 3️⃣ One that fetches and subscribes to messages that were created after the timestamp of the `CuteChat` intialization (only new messages) + +No caching is ever emptied, but only accordingly updated based on the snapshot event changes, meaning the list will persist, and minimize the disruption for the user. diff --git a/src/utils/appendSnapshot.ts b/src/utils/appendSnapshot.ts new file mode 100644 index 0000000..328549c --- /dev/null +++ b/src/utils/appendSnapshot.ts @@ -0,0 +1,52 @@ +import { IMessage } from 'react-native-gifted-chat'; +import { SnapshotChange } from './prepareSnapshot'; + +/** + * Returns transformed messages based on snapshot events. + * This can include removed, added or modified messages and are updated + * and returned accordingly via this function. + * + * This function **does not** mutate the original `currentMessages` array. + */ +export const appendSnapshot = ( + currentMessages: IMessage[], + snapshotChanges: SnapshotChange[] +): IMessage[] => { + console.log('Appending snapshot...'); + const newMessages = [...currentMessages]; + + for (const change of snapshotChanges) { + switch (change.type) { + case 'removed': + console.log('Message remove id:', change.message._id); + const deleteIndex = newMessages.findIndex( + (m) => change.message._id === m._id + ); + if (deleteIndex === -1) { + console.log('Message does not exist in currentMessage'); + break; + } + newMessages.splice(deleteIndex, 1); + console.log('Message removed'); + break; + case 'added': + case 'modified': + console.log('Message modified or added id:', change.message._id); + const modifiedIndex = newMessages.findIndex( + (m) => change.message._id === m._id + ); + if (modifiedIndex === -1) { + console.log('Message added'); + newMessages.push(change.message); + } else { + console.log('Message updated'); + newMessages[modifiedIndex] = change.message; + } + break; + } + } + + return newMessages.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); +}; diff --git a/src/utils/docToMessage.ts b/src/utils/docToMessage.ts new file mode 100644 index 0000000..b8d17bf --- /dev/null +++ b/src/utils/docToMessage.ts @@ -0,0 +1,48 @@ +import firestore, { + FirebaseFirestoreTypes as FirebaseFirestore, +} from '@react-native-firebase/firestore'; +import type { IMessage } from 'react-native-gifted-chat'; + +/** + * Utility function to convert a Firestore document to a `GiftedChat` message. + */ +export const docToMessage = async ( + doc: FirebaseFirestore.QueryDocumentSnapshot, + chatId: string +): Promise => { + const data = doc.data(); + + if (!data) { + throw new Error('Document data is undefined'); + } + + const [files, sender] = await Promise.all([ + firestore().collection(`chats/${chatId}/messages/${doc.id}/files`).get(), + firestore().doc(data.senderRef._documentPath._parts.join('/')).get(), + ]); + + const image = files?.docs[0]?.data().url; + + // Fetch user data from reference + if (sender) { + return { + _id: doc.id, + createdAt: new Date(data.createdAt), + text: data.content, + user: { _id: data.senderId, ...sender }, + image: image, + readByIds: data.readByIds, + metadata: data.metadata, + }; + } else { + return { + _id: doc.id, + createdAt: new Date(data.createdAt), + text: data.content, + image: image, + system: true, + readByIds: data.readByIds, + metadata: data.metadata, + }; + } +}; diff --git a/src/utils/isCloseToBottom.ts b/src/utils/isCloseToBottom.ts new file mode 100644 index 0000000..a92f52d --- /dev/null +++ b/src/utils/isCloseToBottom.ts @@ -0,0 +1,14 @@ +import { NativeScrollEvent } from 'react-native'; + +export function isCloseToBottom({ + layoutMeasurement, + contentOffset, + contentSize, +}: NativeScrollEvent) { + const paddingToBottom = 500; + + return ( + layoutMeasurement.height + contentOffset.y >= + contentSize.height - paddingToBottom + ); +} diff --git a/src/utils/prepareSnapshot.ts b/src/utils/prepareSnapshot.ts new file mode 100644 index 0000000..53144d0 --- /dev/null +++ b/src/utils/prepareSnapshot.ts @@ -0,0 +1,25 @@ +import { FirebaseFirestoreTypes as FirebaseFirestore } from '@react-native-firebase/firestore'; +import { docToMessage } from './docToMessage'; +import { IMessage } from 'react-native-gifted-chat'; + +export type SnapshotChange = { + type: FirebaseFirestore.DocumentChangeType; + message: IMessage; +}; + +/** + * Prepares snapshot changes for internal usage. + */ +export const prepareSnapshot = async ( + snapshot: FirebaseFirestore.QuerySnapshot, + chatId: string +): Promise => { + console.log('Preparing snapshot'); + + return Promise.all( + snapshot.docChanges().map(async (change) => ({ + type: change.type, + message: await docToMessage(change.doc, chatId), + })) + ); +};