diff --git a/README.md b/README.md index eb6c22b..32a1d80 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,25 @@ export const ChatScreen: React.FC = () => { ``` +## Features + +#### Leave conversation + +- Use `useConversation` hook to get `leaveConversation` function. + +```typescript +import {useConversation} from 'rn-firebase-chat'; + +const {leaveConversation} = useConversation(); + +const result = await leaveConversation(conversationId, isSilent); +``` + +| Parameter | Type | Description | +| :--------------- | :-------- | :------------------------------------------------------------------------ | +| `conversationId` | `string` | **Required** | +| `isSilent` | `boolean` | If `true`, send a system message to the conversation to notify the action | + ## Contributing See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow. diff --git a/src/chat/ChatProvider.tsx b/src/chat/ChatProvider.tsx index 3ff7b2a..ab13fb3 100644 --- a/src/chat/ChatProvider.tsx +++ b/src/chat/ChatProvider.tsx @@ -5,6 +5,7 @@ import { chatReducer, setListConversation, updateConversation, + updateListConversation, } from '../reducer'; const firestoreServices = FirestoreServices.getInstance(); @@ -36,7 +37,11 @@ export const ChatProvider: React.FC = ({ dispatch(setListConversation(res)); }); unsubscribeListener = firestoreServices.listenConversationUpdate( - (data) => { + (data, type) => { + if (type === 'removed') { + dispatch(updateListConversation(data)); + return; + } dispatch(updateConversation(data)); } ); diff --git a/src/hooks.ts b/src/hooks.ts index 9478ece..a8f801c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react'; import { useContext } from 'react'; import { ChatContext } from './chat'; import type { ChatState } from './reducer'; +import { FirestoreServices } from './services/firebase'; const useChat = () => { // const firebaseInstant @@ -72,4 +73,10 @@ const useTypingIndicator = ( }; }; -export { useChatContext, useChatSelector, useTypingIndicator }; +const useConversation = () => { + const firebaseInstance = useRef(FirestoreServices.getInstance()).current; + + return { leaveConversation: firebaseInstance.leaveConversation }; +}; + +export { useChatContext, useChatSelector, useTypingIndicator, useConversation }; diff --git a/src/interfaces/message.ts b/src/interfaces/message.ts index a8c3a4a..f432072 100644 --- a/src/interfaces/message.ts +++ b/src/interfaces/message.ts @@ -17,6 +17,7 @@ interface LatestMessageProps { type?: MediaType; path?: string; extension?: string; + system?: boolean; } interface MessageProps extends BaseEntity, IMessage { @@ -29,6 +30,7 @@ interface MessageProps extends BaseEntity, IMessage { type?: MediaType; path?: string; extension?: string; + system?: boolean; } interface SendMessageProps { @@ -42,6 +44,7 @@ interface SendMessageProps { type?: MediaType; path?: string; extension?: string; + system?: boolean; } type MediaType = 'image' | 'video' | 'text' | undefined; diff --git a/src/reducer/action.ts b/src/reducer/action.ts index f2b34b2..e6f0547 100644 --- a/src/reducer/action.ts +++ b/src/reducer/action.ts @@ -5,6 +5,7 @@ export enum ChatActionKind { SET_CONVERSATION = 'SET_CONVERSATION', CLEAR_CONVERSATION = 'CLEAR_CONVERSATION', UPDATE_CONVERSATION = 'UPDATE_CONVERSATION', + UPDATE_LIST_CONVERSATION = 'UPDATE_LIST_CONVERSATION', } export const setListConversation = (payload: ConversationProps[]) => ({ @@ -12,6 +13,11 @@ export const setListConversation = (payload: ConversationProps[]) => ({ payload, }); +export const updateListConversation = (payload: ConversationProps) => ({ + type: ChatActionKind.UPDATE_LIST_CONVERSATION, + payload, +}); + export const setConversation = (payload: ConversationProps) => ({ type: ChatActionKind.SET_CONVERSATION, payload, diff --git a/src/reducer/chat.ts b/src/reducer/chat.ts index af7c99c..00e58aa 100644 --- a/src/reducer/chat.ts +++ b/src/reducer/chat.ts @@ -21,6 +21,17 @@ export const chatReducer = ( ...state, listConversation: action.payload as ConversationProps[], }; + case ChatActionKind.UPDATE_LIST_CONVERSATION: { + const message = action.payload as ConversationProps; + const listConversation = state.listConversation?.filter( + (e) => e.id !== message.id + ); + + return { + ...state, + listConversation: listConversation as ConversationProps[], + }; + } case ChatActionKind.SET_CONVERSATION: return { ...state, @@ -31,7 +42,7 @@ export const chatReducer = ( ...state, conversation: undefined, }; - case ChatActionKind.UPDATE_CONVERSATION: + case ChatActionKind.UPDATE_CONVERSATION: { const message = action.payload as ConversationProps; const isExistID = state.listConversation?.some( (item) => item.id === message.id @@ -52,5 +63,9 @@ export const chatReducer = ( ...state, listConversation: newListConversation, }; + } + + default: + return state; } }; diff --git a/src/services/firebase/firestore.ts b/src/services/firebase/firestore.ts index fbf791a..f66ca86 100644 --- a/src/services/firebase/firestore.ts +++ b/src/services/firebase/firestore.ts @@ -249,14 +249,15 @@ export class FirestoreServices { this.memberIds?.forEach((memberId) => { this.updateUserConversation( memberId, - formatLatestMessage( - this.userId, - this.userInfo?.name || '', - '', + formatLatestMessage({ + userId: this.userId, + name: this.userInfo?.name || '', + message: '', type, path, - extension - ) + extension, + system: false, + }) ); }); } catch (error) { @@ -282,16 +283,30 @@ export class FirestoreServices { message.type === MessageTypes.image || message.type === MessageTypes.video ) { - messageData = formatSendMessage(this.userId, text, type, path, extension); + messageData = formatSendMessage({ + userId: this.userId, + text, + type, + path, + extension, + }); this.sendMessageWithFile(messageData); } else { - /** Format message */ - messageData = formatSendMessage(this.userId, text); - /** Encrypt the message before store to firestore */ - if (this.enableEncrypt && this.encryptKey) { - messageData.text = this.encryptFunctionProp - ? await this.encryptFunctionProp(text) - : await encryptData(text, this.encryptKey); + if (message.system) { + messageData = formatSendMessage({ + userId: this.userId, + text, + system: true, + }); + } else { + /** Format message text */ + messageData = formatSendMessage({ userId: this.userId, text }); + /** Encrypt the message before store to firestore */ + if (this.enableEncrypt && this.encryptKey) { + messageData.text = this.encryptFunctionProp + ? await this.encryptFunctionProp(text) + : await encryptData(text, this.encryptKey); + } } try { @@ -305,11 +320,12 @@ export class FirestoreServices { .add(messageData); /** Format latest message data */ - const latestMessageData = formatLatestMessage( - this.userId, - this.userInfo?.name || '', - messageData.text - ); + const latestMessageData = formatLatestMessage({ + userId: this.userId, + name: this.userInfo?.name || '', + message: messageData.text, + system: messageData.system, + }); this.memberIds?.forEach((memberId) => { this.updateUserConversation(memberId, latestMessageData); }); @@ -329,6 +345,8 @@ export class FirestoreServices { ); return; } + if (userId === this.userId && latestMessageData.system) return; + const userConversationRef = firestore() .collection>( this.getUrlWithPrefix( @@ -618,7 +636,9 @@ export class FirestoreServices { ); }; - listenConversationUpdate = (callback: (_: ConversationProps) => void) => { + listenConversationUpdate = ( + callback: (_: ConversationProps, type: string) => void + ) => { const regex = this.regexBlacklist; return firestore() @@ -630,11 +650,11 @@ export class FirestoreServices { .onSnapshot(async (snapshot) => { if (snapshot) { for (const change of snapshot.docChanges()) { + const data = { + ...(change.doc.data() as ConversationProps), + id: change.doc.id, + }; if (change.type === 'modified') { - const data = { - ...(change.doc.data() as ConversationProps), - id: change.doc.id, - }; const message = { ...data, latestMessage: data.latestMessage @@ -646,7 +666,9 @@ export class FirestoreServices { ) : data.latestMessage, } as ConversationProps; - callback?.(message); + callback?.(message, change.type); + } else if (change.type === 'removed') { + callback?.(data, change.type); } } } @@ -677,4 +699,128 @@ export class FirestoreServices { return fileURLs; }; + + private deleteUnreadAndTypingUser = async (conversationId: string) => { + if (!this.userId || !conversationId) { + console.error('User ID or conversation ID is missing'); + return; + } + + const conversationRef = firestore() + .collection( + this.getUrlWithPrefix(`${FireStoreCollection.conversations}`) + ) + .doc(conversationId); + + try { + const conversationDoc = await conversationRef.get(); + if (conversationDoc.exists) { + const conversationData = conversationDoc.data(); + if (conversationData?.unRead?.[this.userId]) { + const updatedUnread = { ...conversationData.unRead }; + delete updatedUnread[this.userId]; + + await conversationRef.update({ unRead: updatedUnread }); + const updatedTyping = { ...conversationData.typing }; + delete updatedTyping[this.userId]; + await conversationRef.update({ typing: updatedTyping }); + } else { + console.log('User not found in unRead or no unRead field present'); + } + } else { + console.log('Conversation document does not exist'); + } + } catch (error) { + console.error('Error removing user from unRead: ', error); + } + }; + + private removeUserFromOtherMemberConversation = async ( + conversationId: string, + userId: string + ): Promise => { + const leftConversation = firestore() + .collection( + this.getUrlWithPrefix( + `${FireStoreCollection.users}/${userId}/${FireStoreCollection.conversations}` + ) + ) + .doc(conversationId); + + try { + const leftConversationDoc = await leftConversation.get(); + if (!leftConversationDoc.exists) { + console.error('Conversation document does not exist'); + return null; + } + + const leftConversationData = + leftConversationDoc.data() as ConversationProps; + const newMembers = leftConversationData?.members?.filter( + (e) => e !== userId + ); + + const batch = firestore().batch(); + newMembers?.forEach((id) => { + if (id) { + const doc = firestore() + .collection( + this.getUrlWithPrefix( + `${FireStoreCollection.users}/${id}/${FireStoreCollection.conversations}` + ) + ) + .doc(conversationId); + batch.set(doc, { members: newMembers }, { merge: true }); + } + }); + + await batch.commit(); + return leftConversationData; + } catch (error) { + console.error('Error updating conversation members: ', error); + return null; + } + }; + + leaveConversation = async ( + conversationId: string, + isSilent: boolean = false + ): Promise => { + try { + if (!conversationId) { + console.error('Conversation document does not exist'); + } + await this.deleteUnreadAndTypingUser(conversationId); + + const leftConversationData = + await this.removeUserFromOtherMemberConversation( + conversationId, + this.userId + ); + + if (!leftConversationData) return false; + + if (!isSilent) { + await this.sendMessage({ + text: `${this.userInfo?.name} left the conversation`, + system: true, + } as MessageProps); + } + + const leftConversation = firestore() + .collection( + this.getUrlWithPrefix( + `${FireStoreCollection.users}/${this.userId}/${FireStoreCollection.conversations}` + ) + ) + .doc(conversationId); + + await leftConversation.delete(); + + return true; + } catch (e) { + console.error('Error leaving conversation: ', e); + return false; + } + }; } diff --git a/src/utilities/messageFormatter.ts b/src/utilities/messageFormatter.ts index f1bf00f..907b640 100644 --- a/src/utilities/messageFormatter.ts +++ b/src/utilities/messageFormatter.ts @@ -14,6 +14,25 @@ import { import { getTextMessage } from './blacklist'; import { getCurrentTimestamp } from './date'; +interface FormatSendMessageParams { + userId: string; + text: string; + type?: MediaType; + path?: string; + extension?: string; + system?: boolean; +} + +interface FormatLatestMessageParams { + userId: string; + name: string; + message: string; + type?: MediaType; + path?: string; + extension?: string; + system?: boolean; +} + const convertTextMessage = async ( text: string, regex?: RegExp, @@ -64,9 +83,9 @@ const formatMessageData = async ( _id: message.id, createdAt: message.createdAt || getCurrentTimestamp(), user: { - _id: userInfo.id, - name: userInfo.name, - avatar: userInfo.avatar, + _id: userInfo?.id, + name: userInfo?.name, + avatar: userInfo?.avatar, }, }; }; @@ -78,13 +97,14 @@ const formatdecryptedMessageData = async ( return await decryptedMessageData(text, conversationId); }; -const formatSendMessage = ( - userId: string, - text: string, - type?: MediaType, - path?: string, - extension?: string -): SendMessageProps => ({ +const formatSendMessage = ({ + userId, + text, + type, + path, + extension, + system, +}: FormatSendMessageParams): SendMessageProps => ({ readBy: { [userId]: true, }, @@ -95,16 +115,18 @@ const formatSendMessage = ( type: type ?? MessageTypes.text, path: path ?? '', extension: extension ?? '', + system: system ?? false, }); -const formatLatestMessage = ( - userId: string, - name: string, - message: string, - type?: MediaType, - path?: string, - extension?: string -): LatestMessageProps => ({ +const formatLatestMessage = ({ + userId, + name, + message, + type, + path, + extension, + system, +}: FormatLatestMessageParams): LatestMessageProps => ({ text: message ?? '', senderId: userId, name: name, @@ -114,6 +136,7 @@ const formatLatestMessage = ( type: type ?? MessageTypes.text, path: path ?? '', extension: extension ?? '', + system: system ?? false, }); export const getMediaTypeFromExtension = (path: string): MediaType => {