diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0cd98..a50c94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.27.0 + +### xxx + +- xxx + ## 0.26.2 ### Misc diff --git a/package-lock.json b/package-lock.json index 061f18e..8f1784a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@connectycube/use-chat", - "version": "0.26.2", + "version": "0.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@connectycube/use-chat", - "version": "0.26.2", + "version": "0.27.0", "license": "Apache-2.0", "dependencies": { "date-fns": "^4.1.0", - "react-usestateref": "^1.0.9" + "react-usestateref": "^1.0.9", + "zustand": "^5.0.6" }, "devDependencies": { "@rollup/plugin-commonjs": "^28.0.6", @@ -1416,7 +1417,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2647,7 +2648,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-uri-to-buffer": { @@ -6724,6 +6725,35 @@ "engines": { "node": ">= 14.6" } + }, + "node_modules/zustand": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz", + "integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index f0e8244..5ecb032 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@connectycube/use-chat", "description": "A React hook for state management in ConnectyCube-powered chat solutions", - "version": "0.26.2", + "version": "0.27.0", "homepage": "https://github.com/ConnectyCube/use-chat", "keywords": [ "react", @@ -40,7 +40,8 @@ }, "dependencies": { "date-fns": "^4.1.0", - "react-usestateref": "^1.0.9" + "react-usestateref": "^1.0.9", + "zustand": "^5.0.6" }, "devDependencies": { "@rollup/plugin-commonjs": "^28.0.6", diff --git a/src/ChatContext.tsx b/src/ChatContext.tsx index 9fae82b..c19d449 100644 --- a/src/ChatContext.tsx +++ b/src/ChatContext.tsx @@ -1,13 +1,85 @@ -import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react"; import useStateRef from "react-usestateref"; import ConnectyCube from "connectycube"; import { Chat, ChatEvent, ChatType, Dialogs, DialogType, Messages } from "connectycube/types"; import { formatDistanceToNow } from "date-fns"; -import { ChatContextType, ChatProviderType, ChatStatus, DialogEventSignal, MessageStatus } from "./types"; -import { useBlockList, useNetworkStatus, useUsers } from "./hooks"; +import { useBlockList, useChatStore, useNetworkStatus, useUsers } from "./hooks"; import { getDialogTimestamp, parseDate } from "./helpers"; +import { BlockListHook } from "./hooks/useBlockList"; +import { UsersHookExports } from "./hooks/useUsers"; + +export enum DialogEventSignal { + ADDED_TO_DIALOG = "dialog/ADDED_TO_DIALOG", + REMOVED_FROM_DIALOG = "dialog/REMOVED_FROM_DIALOG", + ADD_PARTICIPANTS = "dialog/ADD_PARTICIPANTS", + REMOVE_PARTICIPANTS = "dialog/REMOVE_PARTICIPANTS", + NEW_DIALOG = "dialog/NEW_DIALOG", +} + +export enum MessageStatus { + WAIT = "wait", + LOST = "lost", + SENT = "sent", + READ = "read", +} + +export enum ChatStatus { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", + NOT_AUTHORIZED = "not-authorized", + ERROR = "error", +} +export interface ChatProviderType { + children?: ReactNode; +} +export interface ChatContextType extends BlockListHook, UsersHookExports { + isOnline: boolean; + isConnected: boolean; + chatStatus: ChatStatus; + connect: (credentials: Chat.ConnectionParams) => Promise; + disconnect: () => Promise; + terminate: () => void; + currentUserId?: number; + createChat: (userId: number, extensions?: { [key: string]: any }) => Promise; + createGroupChat: ( + usersIds: number[], + name: string, + photo?: string, + extensions?: { [key: string]: any }, + ) => Promise; + getDialogs: (filters?: Dialogs.ListParams) => Promise; + getNextDialogs: () => Promise; + totalDialogReached: boolean; + dialogs: Dialogs.Dialog[]; + selectedDialog?: Dialogs.Dialog; + selectDialog: (dialog?: Dialogs.Dialog) => Promise; + getDialogOpponentId: (dialog?: Dialogs.Dialog) => number | undefined; + unreadMessagesCount: { total: number; [dialogId: string]: number }; + getMessages: (dialogId: string) => Promise; + getNextMessages: (dialogId: string) => Promise; + totalMessagesReached: { [dialogId: string]: boolean }; + messages: { [key: string]: Messages.Message[] }; + markDialogAsRead: (dialog: Dialogs.Dialog) => Promise; + addUsersToGroupChat: (usersIds: number[]) => Promise; + removeUsersFromGroupChat: (usersIds: number[]) => Promise; + leaveGroupChat: () => Promise; + sendSignal: (userIdOrIds: number | number[], signal: string, params?: any) => void; + sendMessage: (body: string, dialog?: Dialogs.Dialog) => void; + sendMessageWithAttachment: (files: File[], dialog?: Dialogs.Dialog) => Promise; + readMessage: (messageId: string, userId: number, dialogId: string) => void; + sendTypingStatus: (dialog?: Dialogs.Dialog, isTyping?: boolean) => void; + typingStatus: { [dialogId: string]: number[] }; + lastMessageSentTimeString: (dialog: Dialogs.Dialog) => string; + messageSentTimeString: (message: Messages.Message) => string; + processOnSignal: (fn: Chat.OnMessageSystemListener | null) => void; + processOnMessage: (fn: Chat.OnMessageListener | null) => void; + processOnMessageError: (fn: Chat.OnMessageErrorListener | null) => void; + processOnMessageSent: (fn: Chat.OnMessageSentListener | null) => void; +} const ChatContext = createContext(undefined); + ChatContext.displayName = "ChatContext"; export const useChat = (): ChatContextType => { @@ -44,8 +116,10 @@ export const ChatProvider = ({ children }: ChatProviderType): React.ReactElement // internal hooks const chatBlockList = useBlockList(isConnected); const chatUsers = useUsers(currentUserId); - const { isOnline } = useNetworkStatus(isConnected); + const isOnline = useNetworkStatus(isConnected); const { _retrieveAndStoreUsers } = chatUsers; + // global state + // const isOnline = useChatStore((state) => state.isOnline); const connect = async (credentials: Chat.ConnectionParams): Promise => { setChatStatus(ChatStatus.CONNECTING); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f442fef..2f99f47 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,6 @@ export { default as useBlockList } from "./useBlockList"; export { default as useNetworkStatus } from "./useNetworkStatus"; export { default as useUsers } from "./useUsers"; +export { useChat as useChat } from "./useChat"; +export { default as useChatStore } from "./useChatStore"; +export { default as useChatStoreRef } from "./useChatStoreRef"; diff --git a/src/hooks/useBlockList.ts b/src/hooks/useBlockList.ts index 647567c..4357345 100644 --- a/src/hooks/useBlockList.ts +++ b/src/hooks/useBlockList.ts @@ -1,6 +1,8 @@ import ConnectyCube from "connectycube"; import { PrivacyListAction } from "connectycube/types"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useRef } from "react"; +import useChatStore from "./useChatStore"; +import { useShallow } from "zustand/shallow"; export const BLOCK_LIST_LOG_TAG = "[useChat][useBlockList]"; export const BLOCK_LIST_NAME = "ConnectyCubeBlockList"; @@ -13,10 +15,12 @@ export type BlockListHook = { }; function useBlockList(isConnected: boolean): BlockListHook { - const [state, setState] = useState>(new Set()); const isApplied = useRef(false); + const [blockedUsers, setBlockedUsers] = useChatStore( + useShallow((state) => [state.blockedUsers, state.setBlockedUsers]), + ); - const isBlocked = (userId: number): boolean => state.has(userId); + const isBlocked = (userId: number): boolean => blockedUsers.has(userId); const fetch = async (): Promise => { if (!isConnected) { @@ -37,7 +41,7 @@ function useBlockList(isConnected: boolean): BlockListHook { isApplied.current = true; - setState(newState); + setBlockedUsers(newState); } }; @@ -47,7 +51,7 @@ function useBlockList(isConnected: boolean): BlockListHook { return; } - const newState = new Set(state); + const newBlockedUsers = new Set(blockedUsers); const blockList = { name: BLOCK_LIST_NAME, @@ -56,15 +60,15 @@ function useBlockList(isConnected: boolean): BlockListHook { try { if (action === PrivacyListAction.DENY) { - newState.add(user_id); + newBlockedUsers.add(user_id); } else if (action === PrivacyListAction.ALLOW) { - newState.delete(user_id); + newBlockedUsers.delete(user_id); } if (isApplied.current) { await ConnectyCube.chat.privacylist.setAsDefault(null); await ConnectyCube.chat.privacylist.update(blockList); - if (newState.size > 0) { + if (newBlockedUsers.size > 0) { await ConnectyCube.chat.privacylist.setAsDefault(BLOCK_LIST_NAME); } } else { @@ -74,7 +78,7 @@ function useBlockList(isConnected: boolean): BlockListHook { } catch (error) { return; } finally { - setState(newState); + setBlockedUsers(blockedUsers); } }; @@ -103,7 +107,7 @@ function useBlockList(isConnected: boolean): BlockListHook { }, [isConnected]); return { - blockedUsers: Array.from(state), + blockedUsers: Array.from(blockedUsers), isBlockedUser: isBlocked, unblockUser: unblock, blockUser: block, diff --git a/src/hooks/useChat.tsx b/src/hooks/useChat.tsx new file mode 100644 index 0000000..c93704d --- /dev/null +++ b/src/hooks/useChat.tsx @@ -0,0 +1,1021 @@ +import { useEffect, useRef, useState } from "react"; +import useStateRef from "react-usestateref"; +import ConnectyCube from "connectycube"; +import { Chat, ChatEvent, ChatType, Dialogs, DialogType, Messages } from "connectycube/types"; +import { formatDistanceToNow } from "date-fns"; +import useChatStore from "./useChatStore"; +import useBlockList, { BlockListHook } from "./useBlockList"; +import useUsers, { UsersHookExports } from "./useUsers"; +import useNetworkStatus, { NetworkStatusHook } from "./useNetworkStatus"; +import { getDialogTimestamp, parseDate } from "../helpers"; +import { useShallow } from "zustand/shallow"; +import useChatStoreRef from "./useChatStoreRef"; + +export enum DialogEventSignal { + ADDED_TO_DIALOG = "dialog/ADDED_TO_DIALOG", + REMOVED_FROM_DIALOG = "dialog/REMOVED_FROM_DIALOG", + ADD_PARTICIPANTS = "dialog/ADD_PARTICIPANTS", + REMOVE_PARTICIPANTS = "dialog/REMOVE_PARTICIPANTS", + NEW_DIALOG = "dialog/NEW_DIALOG", +} + +export enum MessageStatus { + WAIT = "wait", + LOST = "lost", + SENT = "sent", + READ = "read", +} + +export enum ChatStatus { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", + NOT_AUTHORIZED = "not-authorized", + ERROR = "error", +} + +export interface ChatHook extends BlockListHook, UsersHookExports, NetworkStatusHook { + isConnected: boolean; + chatStatus: ChatStatus; + connect: (credentials: Chat.ConnectionParams) => Promise; + disconnect: () => Promise; + terminate: () => void; + currentUserId?: number; + createChat: (userId: number, extensions?: { [key: string]: any }) => Promise; + createGroupChat: ( + usersIds: number[], + name: string, + photo?: string, + extensions?: { [key: string]: any }, + ) => Promise; + getDialogs: (filters?: Dialogs.ListParams) => Promise; + getNextDialogs: () => Promise; + totalDialogReached: boolean; + dialogs: Dialogs.Dialog[]; + selectedDialog?: Dialogs.Dialog; + selectDialog: (dialog?: Dialogs.Dialog) => Promise; + getDialogOpponentId: (dialog?: Dialogs.Dialog) => number | undefined; + unreadMessagesCount: { total: number; [dialogId: string]: number }; + getMessages: (dialogId: string) => Promise; + getNextMessages: (dialogId: string) => Promise; + totalMessagesReached: { [dialogId: string]: boolean }; + messages: { [key: string]: Messages.Message[] }; + markDialogAsRead: (dialog: Dialogs.Dialog) => Promise; + addUsersToGroupChat: (usersIds: number[]) => Promise; + removeUsersFromGroupChat: (usersIds: number[]) => Promise; + leaveGroupChat: () => Promise; + sendSignal: (userIdOrIds: number | number[], signal: string, params?: any) => void; + sendMessage: (body: string, dialog?: Dialogs.Dialog) => void; + sendMessageWithAttachment: (files: File[], dialog?: Dialogs.Dialog) => Promise; + readMessage: (messageId: string, userId: number, dialogId: string) => void; + sendTypingStatus: (dialog?: Dialogs.Dialog, isTyping?: boolean) => void; + typingStatus: { [dialogId: string]: number[] }; + lastMessageSentTimeString: (dialog: Dialogs.Dialog) => string; + messageSentTimeString: (message: Messages.Message) => string; + processOnSignal: (fn: Chat.OnMessageSystemListener | null) => void; + processOnMessage: (fn: Chat.OnMessageListener | null) => void; + processOnMessageError: (fn: Chat.OnMessageErrorListener | null) => void; + processOnMessageSent: (fn: Chat.OnMessageSentListener | null) => void; +} + +export const ChatProvider = (): ChatHook => { + // refs + const typingTimers = useRef<{ [dialogId: string]: { [userId: number | string]: NodeJS.Timeout } }>({}); + const onMessageRef = useRef(null); + const onSignalRef = useRef(null); + const onMessageSentRef = useRef(null); + const onMessageErrorRef = useRef(null); + const activatedDialogsRef = useRef<{ [dialogId: string]: boolean }>({}); + const privateDialogsIdsRef = useRef<{ [userId: number | string]: string }>({}); + // global state + const [ + isOnline, + isConnected, + setIsConnected, + unreadMessagesCount, + setUnreadMessagesCount, + typingStatus, + setTypingStatus, + totalMessagesReached, + setTotalMessagesReached, + totalDialogReached, + setTotalDialogReached, + ] = useChatStore( + useShallow((state) => [ + state.isOnline, + state.isConnected, + state.setIsConnected, + state.unreadMessagesCount, + state.setUnreadMessagesCount, + state.typingStatus, + state.setTypingStatus, + state.totalMessagesReached, + state.setTotalMessagesReached, + state.totalDialogReached, + state.setTotalDialogReached, + ]), + ); + // state + const [isConnected, setIsConnected] = useState(false); + const [unreadMessagesCount, setUnreadMessagesCount] = useState({ total: 0 }); + const [typingStatus, setTypingStatus] = useState<{ [dialogId: string]: number[] }>({}); + const [totalMessagesReached, setTotalMessagesReached] = useState<{ [dialogId: string]: boolean }>({}); + const [totalDialogReached, setTotalDialogReached] = useState(false); + // state refs + // const messagesRef = useChatStoreRef('messages'); + // const dialogsRef = useChatStoreRef('dialogs'); + // const currentUserIdRef = useChatStoreRef('currentUserId'); + // const selectedDialogRef = useChatStoreRef('selectedDialog'); + // const chatStatusRef = useChatStoreRef('chatStatus'); + const [messages, setMessages, messagesRef] = useStateRef<{ [dialogId: string]: Messages.Message[] }>({}); + const [dialogs, setDialogs, dialogsRef] = useStateRef([]); + const [currentUserId, setCurrentUserId, currentUserIdRef] = useStateRef(); + const [selectedDialog, setSelectedDialog, selectedDialogRef] = useStateRef(); + const [chatStatus, setChatStatus, chatStatusRef] = useStateRef(ChatStatus.DISCONNECTED); + // internal hooks + const chatBlockList = useBlockList(isConnected); + const chatUsers = useUsers(currentUserId); + const networkStatus = useNetworkStatus(isConnected); + const { _retrieveAndStoreUsers } = chatUsers; + + const connect = async (credentials: Chat.ConnectionParams): Promise => { + setChatStatus(ChatStatus.CONNECTING); + + try { + const _isConnected = await ConnectyCube.chat.connect(credentials); + + if (_isConnected) { + setChatStatus(ChatStatus.CONNECTED); + setIsConnected(_isConnected); + setCurrentUserId(credentials.userId); + } + + return _isConnected; + } catch (error) { + setChatStatus(ChatStatus.DISCONNECTED); + console.error(`Failed to connect due to ${error}`); + + return false; + } + }; + + const disconnect = async (status: ChatStatus = ChatStatus.DISCONNECTED): Promise => { + let disconnected = false; + + if (ConnectyCube.chat.isConnected) { + disconnected = await ConnectyCube.chat.disconnect(); + + setIsConnected(false); + setCurrentUserId(undefined); + setChatStatus(status); + _resetDialogsAndMessagesProgress(); + } + + return disconnected; + }; + + const terminate = (status: ChatStatus = ChatStatus.DISCONNECTED): void => { + ConnectyCube.chat.terminate(); + setChatStatus(status); + _resetDialogsAndMessagesProgress(); + _markMessagesAsLostInStore(); + }; + + const _resetDialogsAndMessagesProgress = () => { + activatedDialogsRef.current = {}; + setTotalDialogReached(false); + setTotalMessagesReached({}); + }; + + const _establishConnection = async (online: boolean) => { + if (online && chatStatusRef.current !== ChatStatus.ERROR) { + if (chatStatusRef.current === ChatStatus.DISCONNECTED || chatStatusRef.current === ChatStatus.NOT_AUTHORIZED) { + setChatStatus(ChatStatus.CONNECTING); + } + } else { + try { + await ConnectyCube.chat.pingWithTimeout(1000); + setChatStatus(ChatStatus.CONNECTED); + } catch (error) { + terminate(); + } + } + }; + + const createChat = async (userId: number, extensions?: { [key: string]: any }): Promise => { + const params = { type: DialogType.PRIVATE, occupants_ids: [userId], extensions }; + const dialog = await ConnectyCube.chat.dialog.create(params); + + setDialogs((prevDialogs) => [dialog, ...prevDialogs.filter((d) => d._id !== dialog._id)]); + setTotalMessagesReached((prevState) => ({ ...prevState, [dialog._id]: true })); + + privateDialogsIdsRef.current[userId] = dialog._id; + + _notifyUsers(DialogEventSignal.NEW_DIALOG, dialog._id, userId); + _retrieveAndStoreUsers([userId, currentUserId as number]); + + return dialog; + }; + + const createGroupChat = async ( + usersIds: number[], + name: string, + photo?: string, + extensions?: { [key: string]: any }, + ): Promise => { + const params = { name, photo, type: DialogType.GROUP, occupants_ids: usersIds, extensions }; + const dialog = await ConnectyCube.chat.dialog.create(params); + + setDialogs((prevDialogs) => [dialog, ...prevDialogs.filter((d) => d._id !== dialog._id)]); + setTotalMessagesReached((prevState) => ({ ...prevState, [dialog._id]: true })); + + usersIds.forEach((userId) => { + _notifyUsers(DialogEventSignal.NEW_DIALOG, dialog._id, userId); + }); + _retrieveAndStoreUsers([...usersIds, currentUserId as number]); + + return dialog; + }; + + const getDialogs = async (filters?: Dialogs.ListParams): Promise => { + const params = { sort_desc: "date_sent", limit: 100, skip: 0, ...filters }; + const { items: fetchedDialogs, skip, limit, total_entries } = await ConnectyCube.chat.dialog.list(params); + const reached = skip + limit >= total_entries; + + setTotalDialogReached(reached); + setDialogs((prevDialogs) => { + const allDialogs = [...prevDialogs, ...fetchedDialogs]; + const uniqueDialogs = Array.from(new Map(allDialogs.map((d) => [d._id, d])).values()); + return uniqueDialogs.sort((a, b) => getDialogTimestamp(b) - getDialogTimestamp(a)); + }); + + const usersIds = fetchedDialogs.flatMap((dialog) => dialog.occupants_ids); + const uniqueUsersIds = Array.from(new Set(usersIds)); + + _retrieveAndStoreUsers(uniqueUsersIds); + + return fetchedDialogs; + }; + + const getNextDialogs = async (): Promise => { + const skip = dialogsRef.current.length; + + return getDialogs({ skip }); + }; + + const _listMessagesByDialogId = async ( + dialogId: string, + listParams: Messages.ListParams = {}, + ): Promise => { + const params = { chat_dialog_id: dialogId, sort_desc: "date_sent", limit: 100, skip: 0, ...listParams }; + + try { + const { items: fetchedMessages, skip, limit } = await ConnectyCube.chat.message.list(params); + const existedMessages = messagesRef.current[dialogId] ?? []; + const reached = skip + limit > fetchedMessages.length + existedMessages.length; + + setTotalMessagesReached((prevState) => ({ ...prevState, [dialogId]: reached })); + + return fetchedMessages + .sort((a: Messages.Message, b: Messages.Message) => { + return a._id.toString().localeCompare(b._id.toString()); // revers sort + }) + .map((msg) => { + const attachments = msg.attachments?.map((attachment) => ({ + ...attachment, + url: ConnectyCube.storage.privateUrl(attachment.uid), + })); + return { ...msg, attachments, status: msg.read ? MessageStatus.READ : MessageStatus.SENT }; + }); + } catch (error: any) { + if (error.code === 404) { + return []; // dialog not found + } + throw error; + } + }; + + const getMessages = async (dialogId: string): Promise => { + try { + const retrievedMessages = await _listMessagesByDialogId(dialogId); + + setMessages((prevMessages) => ({ ...prevMessages, [dialogId]: retrievedMessages })); + + return retrievedMessages; + } catch (error: any) { + throw error; + } + }; + + const getNextMessages = async (dialogId: string): Promise => { + const dialogMessages = messagesRef.current[dialogId] ?? []; + const skip = dialogMessages.length; + + try { + const retrievedMessages = await _listMessagesByDialogId(dialogId, { skip }); + const allDialogMessages = [...retrievedMessages, ...dialogMessages]; + + setMessages((prevMessages) => ({ ...prevMessages, [dialogId]: allDialogMessages })); + + return allDialogMessages; + } catch (error: any) { + throw error; + } + }; + + const selectDialog = async (dialog?: Dialogs.Dialog): Promise => { + setSelectedDialog(dialog); + + if (!dialog) return; + + // retrieve messages if chat is not activated yet + if (!activatedDialogsRef.current[dialog._id]) { + await getMessages(dialog._id); + activatedDialogsRef.current[dialog._id] = true; + } + + if (dialog.unread_messages_count > 0) { + await markDialogAsRead(dialog).catch((_error) => {}); + } + }; + + const getDialogOpponentId = (dialog?: Dialogs.Dialog): number | undefined => { + dialog ??= selectedDialog; + + if (!dialog) { + throw "No dialog provided. You need to provide a dialog via function argument or select a dialog via 'selectDialog'."; + } + + if (dialog.type !== DialogType.PRIVATE) { + return undefined; + } + + const opponentId = dialog.occupants_ids.find((oid) => oid !== currentUserId); + + if (opponentId) { + privateDialogsIdsRef.current[opponentId] = dialog._id; + } + + return opponentId; + }; + + const _updateUnreadMessagesCount = () => { + const count: ChatHook["unreadMessagesCount"] = { total: 0 }; + + dialogs.forEach(({ _id, unread_messages_count = 0 }: Dialogs.Dialog) => { + if (_id !== selectedDialog?._id) { + count[_id] = unread_messages_count; + count.total += unread_messages_count; + } + }); + + setUnreadMessagesCount(count); + }; + + const markDialogAsRead = async (dialog: Dialogs.Dialog): Promise => { + const params = { read: 1, chat_dialog_id: dialog._id }; + await ConnectyCube.chat.message.update("", params); + + setDialogs((prevDialogs) => + prevDialogs.map((d) => (d._id === dialog._id ? { ...d, unread_messages_count: 0 } : d)), + ); + }; + + const addUsersToGroupChat = async (usersIds: number[]): Promise => { + if (!selectedDialog) { + throw new Error("No dialog selected"); + } + + const dialogId = selectedDialog._id; + const toUpdateParams = { push_all: { occupants_ids: usersIds } }; + + await ConnectyCube.chat.dialog.update(dialogId, toUpdateParams); + + selectedDialog.occupants_ids + .filter((userId) => userId !== currentUserId) + .forEach((userId) => { + _notifyUsers(DialogEventSignal.ADD_PARTICIPANTS, dialogId, userId, { + addedParticipantsIds: usersIds.join(), + }); + }); + + usersIds.forEach((userId) => { + _notifyUsers(DialogEventSignal.ADDED_TO_DIALOG, dialogId, userId); + }); + + _retrieveAndStoreUsers(usersIds); + + const updatedDialog = { + ...selectedDialog, + occupants_ids: Array.from(new Set([...selectedDialog.occupants_ids, ...usersIds])), + }; + + setDialogs((prevDialogs) => prevDialogs.map((d) => (d._id === dialogId ? updatedDialog : d))); + setSelectedDialog(updatedDialog); + }; + + const removeUsersFromGroupChat = async (usersIds: number[]): Promise => { + if (!selectedDialog) { + throw new Error("No dialog selected"); + } + + const dialogId = selectedDialog._id; + const toUpdateParams = { pull_all: { occupants_ids: usersIds } }; + + await ConnectyCube.chat.dialog.update(dialogId, toUpdateParams); + + usersIds.forEach((userId) => { + _notifyUsers(DialogEventSignal.REMOVED_FROM_DIALOG, dialogId, userId); + }); + + selectedDialog.occupants_ids + .filter((userId) => { + return !usersIds.includes(userId) && userId !== currentUserId; + }) + .forEach((userId) => { + _notifyUsers(DialogEventSignal.REMOVE_PARTICIPANTS, dialogId, userId, { + removedParticipantsIds: usersIds.join(), + }); + }); + + const updatedDialog = { + ...selectedDialog, + occupants_ids: selectedDialog.occupants_ids.filter((userId) => !usersIds.includes(userId)), + }; + + setDialogs((prevDialogs) => prevDialogs.map((d) => (d._id === dialogId ? updatedDialog : d))); + setSelectedDialog(updatedDialog); + }; + + const leaveGroupChat = async (): Promise => { + if (!selectedDialog) { + throw new Error("No dialog selected"); + } + + await ConnectyCube.chat.dialog.delete(selectedDialog._id); + + selectedDialog.occupants_ids + .filter((userId) => userId !== currentUserId) + .forEach((userId) => { + _notifyUsers(DialogEventSignal.REMOVED_FROM_DIALOG, selectedDialog._id, userId); + }); + + setDialogs(dialogs.filter((dialog) => dialog._id !== selectedDialog._id)); + setSelectedDialog(undefined); + }; + + const sendMessage = (body: string, dialog?: Dialogs.Dialog) => { + dialog ??= selectedDialog; + + if (!dialog) { + throw "No dialog provided. You need to provide a dialog via function argument or select a dialog via 'selectDialog'."; + } + + const opponentId = getDialogOpponentId(dialog); + const messageId = _sendMessage(body, null, dialog, opponentId); + + _addMessageToStore(messageId, body, dialog._id, currentUserId as number, opponentId); + }; + + const sendMessageWithAttachment = async (files: File[], dialog?: Dialogs.Dialog): Promise => { + dialog ??= selectedDialog; + + if (!dialog) { + throw "No dialog provided. You need to provide a dialog via function argument or select a dialog via 'selectDialog'."; + } + + const opponentId = getDialogOpponentId(dialog); + const tempId = Date.now() + ""; + const attachments = files.map((file, index) => ({ + uid: `local-${tempId}-${index}`, // temporary uid + type: file.type, + url: URL.createObjectURL(file), + })); + + _addMessageToStore(tempId, "Attachment", dialog._id, currentUserId as number, opponentId, attachments, true); + + const uploadFilesPromises = files.map((file) => { + const { name, type, size } = file; + const fileParams = { file, name, type, size, public: false }; + return ConnectyCube.storage.createAndUpload(fileParams); + }); + const uploadedFilesResults = await Promise.all(uploadFilesPromises); + const uploadedAttachments = uploadedFilesResults.map(({ uid, content_type = "" }) => ({ + uid, + type: content_type, + url: ConnectyCube.storage.privateUrl(uid), + })); + const messageId = _sendMessage("Attachment", uploadedAttachments, dialog, opponentId); + + setMessages((prevMessages) => ({ + ...prevMessages, + [dialog._id]: prevMessages[dialog._id].map((msg) => + msg._id === tempId + ? { + ...msg, + _id: messageId, + attachments, + isLoading: false, + status: chatStatusRef.current === ChatStatus.CONNECTED ? MessageStatus.WAIT : MessageStatus.LOST, + } + : msg, + ), + })); + }; + + const _sendMessage = ( + body: string, + attachments: Messages.Attachment[] | null, + dialog: Dialogs.Dialog, + opponentId?: number, + ): string => { + const messageParams: Chat.MessageParams = { + type: dialog.type === DialogType.PRIVATE ? ChatType.CHAT : ChatType.GROUPCHAT, + body, + extension: { + save_to_history: 1, + dialog_id: dialog._id, + }, + }; + + if (attachments) { + messageParams.extension.attachments = attachments; + } + + const messageId = ConnectyCube.chat.send( + dialog.type === DialogType.PRIVATE ? (opponentId as number) : dialog._id, + messageParams, + ); + + return messageId; + }; + + const _addMessageToStore = ( + messageId: string, + body: string, + dialogId: string, + senderId: number, + recipientId?: number, + attachments?: Messages.Attachment[], + isLoading?: boolean, + ) => { + const ts = Math.round(new Date().getTime() / 1000); + + setDialogs((prevDialogs) => + prevDialogs + .map((dialog) => + dialog._id === dialogId + ? { + ...dialog, + last_message: body, + last_message_user_id: senderId, + last_message_date_sent: ts, + } + : dialog, + ) + .sort((a, b) => { + const dateA = parseDate(a.last_message_date_sent) || (parseDate(a.created_at) as number); + const dateB = parseDate(b.last_message_date_sent) || (parseDate(b.created_at) as number); + return dateB - dateA; + }), + ); + + setMessages((prevMessages) => ({ + ...prevMessages, + [dialogId]: [ + ...(prevMessages[dialogId] || []), + { + _id: messageId, + created_at: ts, + updated_at: ts, + chat_dialog_id: dialogId, + message: body, + sender_id: senderId, + recipient_id: recipientId as any, + date_sent: ts, + read: 0, + read_ids: [senderId], + delivered_ids: [senderId], + views_count: 0, + attachments: attachments ? attachments : [], + reactions: {} as any, + isLoading, + status: chatStatusRef.current === ChatStatus.CONNECTED ? MessageStatus.WAIT : MessageStatus.LOST, + }, + ], + })); + }; + + const _updateMessageStatusInStore = (status: MessageStatus, messageId: string, dialogId: string, userId?: number) => { + setMessages((prevMessages) => ({ + ...prevMessages, + [dialogId]: + prevMessages[dialogId]?.map((message) => + message._id === messageId + ? { + ...message, + read_ids: userId + ? message.read_ids + ? [...new Set([...message.read_ids, userId])] + : [userId] + : message.read_ids, + read: status === MessageStatus.READ ? 1 : message.read, + status: + status === MessageStatus.SENT && message.status === MessageStatus.LOST ? message.status : status, + } + : message, + ) ?? [], + })); + }; + + const _markMessagesAsLostInStore = () => { + setMessages((prevMessages) => + Object.fromEntries( + Object.entries(prevMessages).map(([dialogId, messages]) => [ + dialogId, + messages.map((message) => + message.status === MessageStatus.WAIT ? { ...message, status: MessageStatus.LOST } : message, + ), + ]), + ), + ); + }; + + const readMessage = (messageId: string, userId: number, dialogId: string) => { + ConnectyCube.chat.sendReadStatus({ messageId, userId, dialogId }); + + _updateMessageStatusInStore(MessageStatus.READ, messageId, dialogId, userId); + + setDialogs((prevDialogs) => + prevDialogs.map((dialog) => + dialog._id === dialogId + ? { + ...dialog, + unread_messages_count: Math.max(0, dialog.unread_messages_count - 1), + } + : dialog, + ), + ); + }; + + const _notifyUsers = (command: string, dialogId: string, userId: number, params: any = {}) => { + const msg = { body: command, extension: { dialogId, ...params } }; + + ConnectyCube.chat.sendSystemMessage(userId, msg); + }; + + const sendSignal = (userIdOrIds: number | number[], signal: string, params: any = {}) => { + const receivers = Array.isArray(userIdOrIds) ? userIdOrIds : [userIdOrIds]; + const msg = { body: signal, extension: params }; + + receivers.forEach((userId) => { + ConnectyCube.chat.sendSystemMessage(userId, msg); + }); + }; + + const sendTypingStatus = (dialog?: Dialogs.Dialog) => { + dialog ??= selectedDialog; + + if (!dialog) { + throw "No dialog provided. You need to provide a dialog via function argument or select a dialog via 'selectDialog'."; + } + + ConnectyCube.chat.sendIsTypingStatus( + dialog.type === DialogType.PRIVATE ? (getDialogOpponentId(dialog) as number) : dialog._id, + ); + }; + + const _updateTypingStatus = (dialogId: string, userId: number, isTyping: boolean) => { + setTypingStatus((prevTypingStatus) => { + const prevUsersIds = prevTypingStatus[dialogId]; + const nextUsersIds = prevUsersIds ? new Set(prevUsersIds) : new Set(); + + isTyping ? nextUsersIds.add(userId) : nextUsersIds.delete(userId); + + return { ...prevTypingStatus, [dialogId]: [...nextUsersIds] }; + }); + }; + + const _clearTypingStatus = (dialogId: string, userId: number) => { + _updateTypingStatus(dialogId, userId, false); + clearTimeout(typingTimers.current[dialogId]?.[userId]); + delete typingTimers.current[dialogId]?.[userId]; + }; + + const _getPrivateDialogIdByUserId = (userId: number): string | undefined => { + let dialogId: string | undefined = privateDialogsIdsRef.current[userId]; + + if (!dialogId) { + const dialog = dialogsRef.current.find( + (dialog) => dialog.type === DialogType.PRIVATE && getDialogOpponentId(dialog) === userId, + ); + + if (dialog) { + dialogId = dialog._id; + privateDialogsIdsRef.current[userId] = dialogId; + } + } + + return dialogId; + }; + + const lastMessageSentTimeString = (dialog: Dialogs.Dialog): string => { + return formatDistanceToNow( + dialog.last_message_date_sent ? (dialog.last_message_date_sent as number) * 1000 : (dialog.created_at as string), + { + addSuffix: true, + }, + ); + }; + + const messageSentTimeString = (message: Messages.Message): string => { + return formatDistanceToNow((message.date_sent as number) * 1000, { + addSuffix: true, + }); + }; + + const processOnMessage = (callbackFn: Chat.OnMessageListener | null) => { + onMessageRef.current = callbackFn; + }; + + const processOnSignal = (callbackFn: Chat.OnMessageSystemListener | null) => { + onSignalRef.current = callbackFn; + }; + + const processOnMessageError = (callbackFn: Chat.OnMessageErrorListener | null) => { + onMessageErrorRef.current = callbackFn; + }; + + const processOnMessageSent = (callbackFn: Chat.OnMessageSentListener | null) => { + onMessageSentRef.current = callbackFn; + }; + + const _processDisconnect = () => { + if (chatStatusRef.current !== ChatStatus.CONNECTING) { + setChatStatus(ChatStatus.DISCONNECTED); + _resetDialogsAndMessagesProgress(); + } + + _markMessagesAsLostInStore(); + }; + + const _processReconnect = () => { + setChatStatus(ChatStatus.CONNECTED); + }; + + const _processConnectionError = async ( + error: { + name?: string; + text?: string; + condition?: string; + [key: string]: any; + } = {}, + ) => { + if ( + error?.condition === "not-authorized" || + error?.text === "Password not verified" || + error?.name === "SASLError" + ) { + const isDisconnected = await disconnect(ChatStatus.NOT_AUTHORIZED); + + if (!isDisconnected) { + terminate(ChatStatus.NOT_AUTHORIZED); + } + } else { + setChatStatus(ChatStatus.ERROR); + } + }; + + const _processMessage = (userId: number, message: Chat.Message) => { + if (onMessageRef.current) { + onMessageRef.current(userId, message); + } + + // TODO: handle multi-device & delivered private messages with delay (from offline) + if (userId === currentUserIdRef.current || (message.delay && message.type === ChatType.CHAT)) { + return; + } + + const currentDialog = selectedDialogRef.current; + const dialogId = message.dialog_id as string; + const messageId = message.id; + const body = message.body || ""; + const opponentId = message.type === ChatType.CHAT ? (currentUserIdRef.current as number) : undefined; + + const attachments = + message.extension.attachments?.length > 0 + ? message.extension.attachments.map((attachment: Messages.Attachment) => ({ + ...attachment, + url: ConnectyCube.storage.privateUrl(attachment.uid), + })) + : undefined; + + _addMessageToStore(messageId, body, dialogId, userId, opponentId, attachments); + _clearTypingStatus(dialogId, userId); + + setDialogs((prevDialogs) => + prevDialogs.map((dialog) => + dialog._id === dialogId + ? { + ...dialog, + unread_messages_count: + !currentDialog || currentDialog._id !== message.dialog_id + ? (dialog.unread_messages_count || 0) + 1 + : dialog.unread_messages_count, + last_message: message.body, + last_message_date_sent: parseInt(message.extension.date_sent), + } + : dialog, + ), + ); + }; + + const _processErrorMessage = (messageId: string, error: { code: number; info: string }) => { + if (onMessageErrorRef.current) { + onMessageErrorRef.current(messageId, error); + } + }; + + const _processSentMessage = (lost: Chat.MessageParams | null, sent: Chat.MessageParams | null) => { + if (onMessageSentRef.current) { + onMessageSentRef.current(lost, sent); + } + + const nextStatus = sent ? MessageStatus.SENT : lost ? MessageStatus.LOST : undefined; + const messageId = sent ? sent.id : lost ? lost.id : undefined; + const dialogId = sent ? sent.extension.dialog_id : lost ? lost.extension.dialog_id : undefined; + + if (nextStatus && messageId && dialogId) { + _updateMessageStatusInStore(nextStatus, messageId, dialogId); + } + }; + + const _processSystemMessage = async (message: Chat.SystemMessage) => { + const dialogId = message.extension.dialogId; + const senderId = message.userId; + + if (onSignalRef.current) { + onSignalRef.current(message); + } + + // TODO: handle multi-device + if (senderId === currentUserIdRef.current) return; + + switch (message.body) { + case DialogEventSignal.NEW_DIALOG: + case DialogEventSignal.ADDED_TO_DIALOG: { + const result = await ConnectyCube.chat.dialog.list({ _id: dialogId }); + const dialog = result.items[0]; + + _retrieveAndStoreUsers(dialog.occupants_ids); + setDialogs((prevDialogs) => [dialog, ...prevDialogs.filter((d) => d._id !== dialog._id)]); + + break; + } + + case DialogEventSignal.ADD_PARTICIPANTS: { + const usersIds = message.extension.addedParticipantsIds.split(",").map(Number) as number[]; + + _retrieveAndStoreUsers(usersIds); + setDialogs((prevDialogs) => + prevDialogs.map((d) => { + if (d._id === dialogId) { + d.occupants_ids = Array.from(new Set([...d.occupants_ids, ...usersIds])); + } + return d; + }), + ); + + break; + } + + case DialogEventSignal.REMOVE_PARTICIPANTS: { + const usersIds = message.extension.removedParticipantsIds.split(",").map(Number); + + setDialogs((prevDialogs) => + prevDialogs.map((d) => { + if (d._id === dialogId) { + d.occupants_ids = d.occupants_ids.filter((id) => !usersIds.includes(id)); + } + return d; + }), + ); + + break; + } + + case DialogEventSignal.REMOVED_FROM_DIALOG: { + setDialogs((prevDialogs) => + prevDialogs.map((d) => { + if (d._id === dialogId && d.type !== DialogType.PRIVATE) { + d.occupants_ids = d.occupants_ids.filter((id) => id !== senderId); + } + return d; + }), + ); + + break; + } + } + }; + + const _processReadMessageStatus = (messageId: string, dialogId: string, userId: number) => { + // TODO: handle multi-device + if (userId === currentUserIdRef.current) return; + + _updateMessageStatusInStore(MessageStatus.READ, messageId, dialogId, userId); + }; + + const _processTypingMessageStatus = (isTyping: boolean, userId: number, dialogId: string | null) => { + const _dialogId = dialogId || _getPrivateDialogIdByUserId(userId); + + // TODO: handle multi-device + if (!_dialogId || !userId || userId === currentUserIdRef.current) return; + + _updateTypingStatus(_dialogId, userId, isTyping); + + if (!typingTimers.current[_dialogId]) { + typingTimers.current[_dialogId] = {}; + } + + if (isTyping) { + if (typingTimers.current[_dialogId][userId]) { + clearTimeout(typingTimers.current[_dialogId][userId]); + delete typingTimers.current[_dialogId][userId]; + } + + typingTimers.current[_dialogId][userId] = setTimeout(() => { + _clearTypingStatus(_dialogId, userId); + }, 6000); + } else { + _clearTypingStatus(_dialogId, userId); + } + }; + + useEffect(() => { + ConnectyCube.chat.addListener(ChatEvent.DISCONNECTED, _processDisconnect); + ConnectyCube.chat.addListener(ChatEvent.RECONNECTED, _processReconnect); + ConnectyCube.chat.addListener(ChatEvent.ERROR, _processConnectionError); + ConnectyCube.chat.addListener(ChatEvent.MESSAGE, _processMessage); + ConnectyCube.chat.addListener(ChatEvent.ERROR_MESSAGE, _processErrorMessage); + ConnectyCube.chat.addListener(ChatEvent.SENT_MESSAGE, _processSentMessage); + ConnectyCube.chat.addListener(ChatEvent.SYSTEM_MESSAGE, _processSystemMessage); + ConnectyCube.chat.addListener(ChatEvent.READ_MESSAGE, _processReadMessageStatus); + ConnectyCube.chat.addListener(ChatEvent.TYPING_MESSAGE, _processTypingMessageStatus); + + return () => { + ConnectyCube.chat.removeAllListeners(); + }; + }, []); + + useEffect(() => { + _updateUnreadMessagesCount(); + }, [dialogs]); + + useEffect(() => { + _establishConnection(isOnline); + }, [isOnline]); + + return { + isOnline, + isConnected, + chatStatus, + connect, + disconnect, + terminate, + currentUserId, + selectDialog, + selectedDialog, + getDialogOpponentId, + unreadMessagesCount, + getMessages, + getNextMessages, + totalMessagesReached, + messages, + sendSignal, + sendMessage, + dialogs, + getDialogs, + getNextDialogs, + totalDialogReached, + createChat, + createGroupChat, + sendTypingStatus, + typingStatus, + sendMessageWithAttachment, + markDialogAsRead, + removeUsersFromGroupChat, + addUsersToGroupChat, + leaveGroupChat, + readMessage, + lastMessageSentTimeString, + messageSentTimeString, + processOnSignal, + processOnMessage, + processOnMessageError, + processOnMessageSent, + ...chatBlockList, + ...chatUsers.exports, + ...networkStatus, + }; +}; diff --git a/src/hooks/useChatStore.ts b/src/hooks/useChatStore.ts new file mode 100644 index 0000000..811b371 --- /dev/null +++ b/src/hooks/useChatStore.ts @@ -0,0 +1,96 @@ +import { Users } from "connectycube/types"; +import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; + +export type UserItem = Users.User; +export type UsersArray = UserItem[]; +export type UsersObject = { [key: number]: UserItem }; +export type UsersLastActivity = { [key: number]: string }; + +export interface BlockListStoreState { + blockedUsers: Set; +} +export interface NetworkStatusStoreState { + isOnline: boolean; +} +export interface UsersStoreState { + users: UsersObject; + onlineUsers: UsersObject; + onlineUsersCount: number; + lastActivity: UsersLastActivity; +} +export interface ChatStoreState extends BlockListStoreState, NetworkStatusStoreState, UsersStoreState {} + +export interface BlockListStoreActions { + setBlockedUsers: (blockedUsers: Set) => void; +} +export interface NetworkStatusStoreActions { + setIsOnline: (isOnline: boolean) => void; +} +export interface UsersStoreActions { + upsertUser: (user: UserItem) => void; + upsertUsers: (users: UsersArray) => void; + setOnlineUsers: (onlineUsers: UsersArray) => void; + updateOnlineUser: (onlineUser: UserItem) => void; + updateOnlineUsers: (onlineUsers: UsersArray) => void; + setOnlineUsersCount: (onlineUsersCount: number) => void; + upsertLastActivity: (userId: number, status: string) => void; +} +export interface ChatStoreActions extends NetworkStatusStoreActions, BlockListStoreActions, UsersStoreActions { + resetStore: () => void; +} + +interface ChatStore extends ChatStoreState, ChatStoreActions {} + +const initialBlockListState = { + blockedUsers: new Set(), +}; +const initialNetworkStatusState = { + isOnline: navigator.onLine, +}; +const initialUsersState = { + users: {}, + onlineUsers: {}, + onlineUsersCount: 0, + lastActivity: {}, +}; +const initialState: ChatStoreState = { + ...initialBlockListState, + ...initialNetworkStatusState, + ...initialUsersState, +}; + +const useChatStore = create()( + subscribeWithSelector((set, get) => ({ + ...initialState, + // Block list + setBlockedUsers: (blockedUsers: Set) => set({ blockedUsers }), + // Network + setIsOnline: (isOnline?: boolean) => set({ isOnline }), + // Users & online users + upsertUser: (user: UserItem) => set({ users: { ...get().users, [user.id]: user } }), + upsertUsers: (users: UsersArray) => + set({ users: users.reduce((map, user) => ({ ...map, [user.id]: user }), { ...get().users }) }), + setOnlineUsers: (onlineUsers: UsersArray) => + set({ + onlineUsers: onlineUsers.reduce((map, user) => ({ ...map, [user.id]: user }), {}), + }), + updateOnlineUser: (onlineUser: UserItem) => + get().onlineUsers[onlineUser.id] + ? set({ onlineUsers: { ...get().onlineUsers, [onlineUser.id]: onlineUser } }) + : void 0, + updateOnlineUsers: (users: UsersArray) => + set({ + onlineUsers: users.reduce((map, user) => (map[user.id] ? { ...map, [user.id]: user } : map), { + ...get().onlineUsers, + }), + }), + setOnlineUsersCount: (onlineUsersCount: number) => set({ onlineUsersCount }), + upsertLastActivity: (userId: number, status: string) => + set({ lastActivity: { ...get().lastActivity, [userId]: status } }), + // Chat, Dialogs + resetStore: () => set({ ...initialState }), + })), +); + +export default useChatStore; diff --git a/src/hooks/useChatStoreRef.ts b/src/hooks/useChatStoreRef.ts new file mode 100644 index 0000000..8d974e0 --- /dev/null +++ b/src/hooks/useChatStoreRef.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef, RefObject } from "react"; +import useChatStore, { ChatStoreState } from "./useChatStore"; + +const useChatStoreRef = (key: K): RefObject => { + const ref = useRef(useChatStore.getState()[key]); + + useEffect(() => { + const unsubscribe = useChatStore.subscribe( + (state) => state[key], + (value) => { + ref.current = value; + }, + ); + return () => unsubscribe(); + }, [key]); + + return ref; +}; + +export default useChatStoreRef; diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index 681bb33..fd480fd 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -1,13 +1,15 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import ConnectyCube from "connectycube"; +import { useShallow } from "zustand/shallow"; +import useChatStore from "./useChatStore"; -export type NetworkStatusHook = { +export interface NetworkStatusHook { isOnline: boolean; -}; +} function useNetworkStatus(isConnected: boolean): NetworkStatusHook { const pingIntervalRef = useRef(undefined); - const [isOnline, setIsOnline] = useState(navigator.onLine); + const [isOnline, setIsOnline] = useChatStore(useShallow((state) => [state.isOnline, state.setIsOnline])); const clearPingInterval = () => { if (pingIntervalRef.current) { @@ -57,9 +59,7 @@ function useNetworkStatus(isConnected: boolean): NetworkStatusHook { }; }, []); - return { - isOnline, - }; + return { isOnline }; } export default useNetworkStatus; diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index c6f2e14..6fd9256 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -1,34 +1,36 @@ import ConnectyCube from "connectycube"; import { Chat, ChatEvent, Users } from "connectycube/types"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { getLastActivityText } from "../helpers"; +import useChatStore from "./useChatStore"; +import { useShallow } from "zustand/shallow"; export const USERS_LOG_TAG = "[useChat][useUsers]"; export const LIMIT_ONLINE_USERS_INTERVAL = 60000; export const LIMIT_FETCH_USER_INTERVAL = 30000; export const MAX_REQUEST_LIMIT = 100; -export type FetchUsersLastRequestAt = { [userId: Users.User["id"]]: number }; -export type OnlineUsersLastRequestAt = number; export type UsersArray = Users.User[]; export type UsersObject = { [userId: Users.User["id"]]: Users.User }; export type UsersLastActivity = { [userId: number]: string }; +export type FetchUsersLastRequestAt = { [userId: Users.User["id"]]: number }; +export type OnlineUsersLastRequestAt = number; -export type UsersHookExports = { +export interface UsersHookExports { users: UsersObject; getAndStoreUsers: (params: Users.GetV2Params) => Promise; searchUsers: (term: string) => Promise; fetchUserById: (id: Users.User["id"], force?: boolean) => Promise; + onlineUsers: UsersArray; listOnlineUsers: (force?: boolean) => Promise; listOnlineUsersWithParams: (params: Users.ListOnlineParams) => Promise; - onlineUsers: UsersArray; - getOnlineUsersCount: () => Promise; onlineUsersCount: number; + getOnlineUsersCount: () => Promise; lastActivity: UsersLastActivity; getLastActivity: (userId: number) => Promise; subscribeToUserLastActivityStatus: (userId: number) => void; unsubscribeFromUserLastActivityStatus: (userId: number) => void; -}; +} export type UsersHook = { exports: UsersHookExports; @@ -36,10 +38,33 @@ export type UsersHook = { }; function useUsers(currentUserId?: number): UsersHook { - const [users, setUsers] = useState({}); - const [onlineUsers, setOnlineUsers] = useState({}); - const [onlineUsersCount, setOnlineUsersCount] = useState(0); - const [lastActivity, setLastActivity] = useState({}); + const [ + users, + upsertUser, + upsertUsers, + onlineUsers, + setOnlineUsers, + updateOnlineUser, + updateOnlineUsers, + onlineUsersCount, + setOnlineUsersCount, + lastActivity, + upsertLastActivity, + ] = useChatStore( + useShallow((state) => [ + state.users, + state.upsertUser, + state.upsertUsers, + state.onlineUsers, + state.setOnlineUsers, + state.updateOnlineUser, + state.updateOnlineUsers, + state.onlineUsersCount, + state.setOnlineUsersCount, + state.lastActivity, + state.upsertLastActivity, + ]), + ); const onlineUsersLastRequestAtRef = useRef(0); const fetchUsersLastRequestAtRef = useRef({}); @@ -47,12 +72,8 @@ function useUsers(currentUserId?: number): UsersHook { const getAndStoreUsers = async (params: Users.GetV2Params): Promise => { const { items } = await ConnectyCube.users.getV2(params); - setUsers((prevUsersState) => - items.reduce((map, user) => ({ ...map, [user.id]: user }), { ...prevUsersState }), - ); - setOnlineUsers((prevState) => - items.reduce((map, user) => (map[user.id] ? { ...map, [user.id]: user } : map), { ...prevState }), - ); + upsertUsers(items); + updateOnlineUsers(items); items.forEach((user) => { fetchUsersLastRequestAtRef.current[user.id] = Date.now(); @@ -81,8 +102,9 @@ function useUsers(currentUserId?: number): UsersHook { const fetchedUser = result?.items?.[0]; if (fetchedUser) { - setUsers((prevState) => ({ ...prevState, [id]: fetchedUser })); - setOnlineUsers((prevState) => (prevState[id] ? { ...prevState, [id]: fetchedUser } : prevState)); + upsertUser(fetchedUser); + updateOnlineUser(fetchedUser); + fetchUsersLastRequestAtRef.current[id] = Date.now(); user = fetchedUser; } @@ -117,6 +139,7 @@ function useUsers(currentUserId?: number): UsersHook { try { const { count } = await ConnectyCube.users.getOnlineCount(); + nextOnlineUsersCount = count; setOnlineUsersCount(nextOnlineUsersCount); } catch (error) { @@ -126,12 +149,10 @@ function useUsers(currentUserId?: number): UsersHook { return nextOnlineUsersCount; }; - const _listOnline = async (): Promise => { + const _listOnline = async (): Promise => { const onlineUsersCount = await getOnlineUsersCount(); const promises = []; - let onlineUsersState: UsersObject = {}; - try { let limit = MAX_REQUEST_LIMIT; let offset = 0; @@ -142,40 +163,30 @@ function useUsers(currentUserId?: number): UsersHook { } const results = await Promise.all(promises); - const allUsers = results.flat(); + const onlineUsers = results.flat(); - onlineUsersState = allUsers.reduce((map, user) => { - map[user.id] = user; - return map; - }, {}); + upsertUsers(onlineUsers); + setOnlineUsers(onlineUsers); - setUsers((prevUsersState) => ({ ...prevUsersState, ...onlineUsersState })); - setOnlineUsers(onlineUsersState); + return onlineUsers; } catch (error) { console.error(`${USERS_LOG_TAG}[listOnline][Error]:`, error); + return []; } - - return onlineUsersState; }; const listOnlineUsersWithParams = async (params: Users.ListOnlineParams): Promise => { - let onlineUsersState: UsersObject = {}; - try { - const { users: allUsers } = await ConnectyCube.users.listOnline(params); + const { users: onlineUsers } = await ConnectyCube.users.listOnline(params); - onlineUsersState = allUsers.reduce((map, user) => { - map[user.id] = user; - return map; - }, {}); + upsertUsers(onlineUsers); + setOnlineUsers(onlineUsers); - setUsers((prevUsersState) => ({ ...prevUsersState, ...onlineUsersState })); - setOnlineUsers(onlineUsersState); + return onlineUsers; } catch (error) { console.error(`${USERS_LOG_TAG}[listOnlineWithParams][Error]:`, error); + return []; } - - return Object.values(onlineUsersState); }; const listOnlineUsers = async (force: boolean = false): Promise => { @@ -183,27 +194,28 @@ function useUsers(currentUserId?: number): UsersHook { const currentTimestamp = Date.now(); const shouldRequest = currentTimestamp - lastRequestedAt > LIMIT_ONLINE_USERS_INTERVAL; - let onlineUsersState = onlineUsers; - if (shouldRequest || force) { - onlineUsersState = await _listOnline(); + const newOnlineUsers = await _listOnline(); + onlineUsersLastRequestAtRef.current = Date.now(); - } - return Object.values(onlineUsersState); + return newOnlineUsers; + } else { + return Object.values(onlineUsers); + } }; const getLastActivity = async (userId: number): Promise => { - let status = "Last seen recently"; - try { const { seconds } = await ConnectyCube.chat.getLastUserActivity(userId); - status = getLastActivityText(seconds); + const status = getLastActivityText(seconds); + + upsertLastActivity(userId, status); + + return status; } catch (error) { console.error(`${USERS_LOG_TAG}[getLastActivity][Error]:`, error); - } finally { - setLastActivity((prevLastActivity) => ({ ...prevLastActivity, [userId]: status })); - return status; + return "Last seen recently"; } }; @@ -221,8 +233,7 @@ function useUsers(currentUserId?: number): UsersHook { seconds: Chat.LastActivity["seconds"], ) => { if (typeof userId === "number" && seconds >= 0) { - const status = getLastActivityText(seconds); - setLastActivity((prevLastActivity) => ({ ...prevLastActivity, [userId]: status })); + upsertLastActivity(userId, getLastActivityText(seconds)); } }; @@ -240,11 +251,11 @@ function useUsers(currentUserId?: number): UsersHook { getAndStoreUsers, searchUsers, fetchUserById, + onlineUsers: Object.values(onlineUsers), listOnlineUsers, listOnlineUsersWithParams, - onlineUsers: Object.values(onlineUsers), - getOnlineUsersCount, onlineUsersCount, + getOnlineUsersCount, lastActivity, getLastActivity, subscribeToUserLastActivityStatus, diff --git a/src/index.ts b/src/index.ts index 8a19b8c..e0851b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1 @@ export * from "./ChatContext"; -export { ChatStatus, MessageStatus } from "./types"; diff --git a/src/tests/helpers.test.ts b/src/tests/helpers.test.ts index 735c825..879318d 100644 --- a/src/tests/helpers.test.ts +++ b/src/tests/helpers.test.ts @@ -1,6 +1,6 @@ +import { describe } from "node:test"; import { expect, test } from "vitest"; import { parseDate, getLastActivityText, getDialogTimestamp } from "../helpers"; -import { describe } from "node:test"; import { LastMessageMessageStatus } from "connectycube/types"; describe("parseDate", () => { diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 5b35f9f..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Chat, Dialogs, Messages } from "connectycube/types"; -import { ReactNode } from "react"; -import { BlockListHook } from "../hooks/useBlockList"; -import { UsersHookExports } from "../hooks/useUsers"; -import { NetworkStatusHook } from "../hooks/useNetworkStatus"; - -export interface ChatProviderType { - children?: ReactNode; -} - -export interface ChatContextType extends BlockListHook, UsersHookExports, NetworkStatusHook { - isConnected: boolean; - chatStatus: ChatStatus; - connect: (credentials: Chat.ConnectionParams) => Promise; - disconnect: () => Promise; - terminate: () => void; - currentUserId?: number; - createChat: (userId: number, extensions?: { [key: string]: any }) => Promise; - createGroupChat: ( - usersIds: number[], - name: string, - photo?: string, - extensions?: { [key: string]: any }, - ) => Promise; - getDialogs: (filters?: Dialogs.ListParams) => Promise; - getNextDialogs: () => Promise; - totalDialogReached: boolean; - dialogs: Dialogs.Dialog[]; - selectedDialog?: Dialogs.Dialog; - selectDialog: (dialog?: Dialogs.Dialog) => Promise; - getDialogOpponentId: (dialog?: Dialogs.Dialog) => number | undefined; - unreadMessagesCount: { total: number; [dialogId: string]: number }; - getMessages: (dialogId: string) => Promise; - getNextMessages: (dialogId: string) => Promise; - totalMessagesReached: { [dialogId: string]: boolean }; - messages: { [key: string]: Messages.Message[] }; - markDialogAsRead: (dialog: Dialogs.Dialog) => Promise; - addUsersToGroupChat: (usersIds: number[]) => Promise; - removeUsersFromGroupChat: (usersIds: number[]) => Promise; - leaveGroupChat: () => Promise; - sendSignal: (userIdOrIds: number | number[], signal: string, params?: any) => void; - sendMessage: (body: string, dialog?: Dialogs.Dialog) => void; - sendMessageWithAttachment: (files: File[], dialog?: Dialogs.Dialog) => Promise; - readMessage: (messageId: string, userId: number, dialogId: string) => void; - sendTypingStatus: (dialog?: Dialogs.Dialog, isTyping?: boolean) => void; - typingStatus: { [dialogId: string]: number[] }; - lastMessageSentTimeString: (dialog: Dialogs.Dialog) => string; - messageSentTimeString: (message: Messages.Message) => string; - processOnSignal: (fn: Chat.OnMessageSystemListener | null) => void; - processOnMessage: (fn: Chat.OnMessageListener | null) => void; - processOnMessageError: (fn: Chat.OnMessageErrorListener | null) => void; - processOnMessageSent: (fn: Chat.OnMessageSentListener | null) => void; -} - -export enum DialogEventSignal { - ADDED_TO_DIALOG = "dialog/ADDED_TO_DIALOG", - REMOVED_FROM_DIALOG = "dialog/REMOVED_FROM_DIALOG", - ADD_PARTICIPANTS = "dialog/ADD_PARTICIPANTS", - REMOVE_PARTICIPANTS = "dialog/REMOVE_PARTICIPANTS", - NEW_DIALOG = "dialog/NEW_DIALOG", -} - -export enum MessageStatus { - WAIT = "wait", - LOST = "lost", - SENT = "sent", - READ = "read", -} - -export enum ChatStatus { - DISCONNECTED = "disconnected", - CONNECTING = "connecting", - CONNECTED = "connected", - NOT_AUTHORIZED = "not-authorized", - ERROR = "error", -}