From 9a506d668be4083976eb4db926e87d20e027a94f Mon Sep 17 00:00:00 2001 From: harrylever Date: Tue, 9 Apr 2024 16:55:03 +0100 Subject: [PATCH 1/2] feat: In App Notifications --- src/AppRoutes.tsx | 2 +- src/app/features/requests.ts | 38 ++- src/app/slices/notificationSlice.ts | 26 ++ src/app/store/store.ts | 12 +- src/components/layout/Layout.tsx | 62 +++-- src/components/molecules/ChatBox/ChatBox.tsx | 93 ++++--- .../PotentialChat/PotentialChatWrap.tsx | 8 +- .../molecules/UserChat/UserChat.tsx | 10 +- src/components/sections/NavBar.tsx | 229 +++++++++++++++--- .../SideBarChatList/SideBarChatList.tsx | 35 +-- src/components/tools/AuthRouteController.tsx | 12 +- src/components/tools/SocketClient.tsx | 50 +++- src/util/manipulate-notification.ts | 7 + src/views/Chat.tsx | 25 +- tailwind.config.js | 3 + typings.ts | 10 +- 16 files changed, 473 insertions(+), 149 deletions(-) create mode 100644 src/app/slices/notificationSlice.ts create mode 100644 src/util/manipulate-notification.ts diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index fdaf834..945cce2 100755 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -1,6 +1,6 @@ +import Layout from './components/layout/Layout'; import { Routes, Route } from 'react-router-dom'; import { ChatView, RegisterView, LoginView, NotFoundView } from './views'; -import Layout from './components/layout/Layout'; export default function AppRoutes() { return ( diff --git a/src/app/features/requests.ts b/src/app/features/requests.ts index d272c3b..0460752 100755 --- a/src/app/features/requests.ts +++ b/src/app/features/requests.ts @@ -1,16 +1,12 @@ import { AxiosInstance } from 'axios'; +import { INotification } from '../../../typings'; -export class PrivateRequestConstruct { - axiosInstance: AxiosInstance; - +// Users +export class UserRequests { // Constructor - constructor(axiosInstance: AxiosInstance) { - this.axiosInstance = axiosInstance; - } + constructor(private readonly axiosInstance: AxiosInstance) {} // Methods - - // Users async useGetUserByIdQuery(userid: string) { const fetch = await this.axiosInstance.get(`/users/${userid}`); return fetch.data; @@ -26,8 +22,12 @@ export class PrivateRequestConstruct { const fetch = await this.axiosInstance.get('/users/'); return fetch.data; } +} + +// Chats +export class ChatRequests { + constructor (private readonly axiosInstance: AxiosInstance) {} - // Chats /** * Fetches all the chats for the currently login user * @param userid @@ -58,8 +58,12 @@ export class PrivateRequestConstruct { }); return fetch.data; } +} + +// Messages +export class MessageRequests { + constructor (private readonly axiosInstance: AxiosInstance) {} - // Messages /** * Gets chat messages by chat id * @param chatid @@ -84,3 +88,17 @@ export class PrivateRequestConstruct { return fetch.data; } } + +export class NotificationRequests { + constructor(private readonly axiosInstance: AxiosInstance) {} + + async usePostModifyNotificationsMutation(notifications: INotification[]) { + const fetch = await this.axiosInstance.post( + '/notifications/mutate-with-sender', + { + notifications, + } + ); + return fetch.data; + } +} \ No newline at end of file diff --git a/src/app/slices/notificationSlice.ts b/src/app/slices/notificationSlice.ts new file mode 100644 index 0000000..20ac3b1 --- /dev/null +++ b/src/app/slices/notificationSlice.ts @@ -0,0 +1,26 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { INotification } from '../../../typings'; + +interface INotificationSlice { + notifications: Array; +} + +const initState: INotificationSlice = { + notifications: [], +}; + +const notificationSlice = createSlice({ + name: 'notificationSlice', + initialState: initState, + reducers: { + setReduxNotifications: ( + state: INotificationSlice, + action: PayloadAction + ) => { + state.notifications = action.payload.notifications; + }, + }, +}); + +export const { setReduxNotifications } = notificationSlice.actions; +export default notificationSlice.reducer; diff --git a/src/app/store/store.ts b/src/app/store/store.ts index bc00e02..0794efd 100755 --- a/src/app/store/store.ts +++ b/src/app/store/store.ts @@ -1,13 +1,14 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { authApiSlice } from '../slices/authApiSlice'; import authReducer from '../slices/authSlice'; import userReducer from '../slices/userSlice'; -import userChatsReducer from '../slices/userChatsSlice'; -import potentialChatsReducer from '../slices/potentialChatsSlice'; import chatReducer from '../slices/chatSlice'; +import { configureStore } from '@reduxjs/toolkit'; +import socketReducer from '../slices/socketSlice'; import messageReducer from '../slices/messagesSlice'; +import { authApiSlice } from '../slices/authApiSlice'; +import userChatsReducer from '../slices/userChatsSlice'; import appUIStateReducer from '../slices/appUIStateSlice'; -import socketReducer from '../slices/socketSlice'; +import notificationReducer from '../slices/notificationSlice'; +import potentialChatsReducer from '../slices/potentialChatsSlice'; const store = configureStore({ reducer: { @@ -19,6 +20,7 @@ const store = configureStore({ messageReduce: messageReducer, appUIStateReduce: appUIStateReducer, socketReduce: socketReducer, + notificationReduce: notificationReducer, [authApiSlice.reducerPath]: authApiSlice.reducer, }, diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 737a05c..5440ff7 100755 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -1,14 +1,19 @@ -import { Outlet } from 'react-router-dom'; -import { NavBarComponent, SideBarChatList } from '../sections'; -import { useEffect, useState } from 'react'; +import axios from 'axios'; import { IUser } from '../../../typings'; +import { Outlet } from 'react-router-dom'; import { useAppSelector } from '../../app'; +import React, { useEffect, useState } from 'react'; +import LoadingPlayer from '../atoms/LoadingPlayer'; +import { NavBarComponent, SideBarChatList } from '../sections'; export default function Layout() { const user = useAppSelector((state) => state.userReduce); - const sideBarChatListIsOpen = useAppSelector((state) => state.appUIStateReduce.sideBarChatOpen); + const sideBarChatListIsOpen = useAppSelector( + (state) => state.appUIStateReduce.sideBarChatOpen + ); const [localuser, setLocalUser] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { if (user && user._id !== '') { @@ -16,23 +21,44 @@ export default function Layout() { } }, [user]); + useEffect(() => { + (async () => { + const handleGetAPIIndex = async () => { + const BASE_URL = import.meta.env.VITE_BE_URL; + const fetch = await axios.get(BASE_URL); + return fetch.data; + }; + + const response = await handleGetAPIIndex(); + if (response.success) { + setIsLoading(false); + } + })(); + }, []); + return ( -
-
- {/* */} - {sideBarChatListIsOpen && } + + {isLoading ? ( + + ) : ( +
+
+ {/* */} + {sideBarChatListIsOpen && } -
-
- -
-
-
-
- +
+
+ +
+
+
+
+ +
+
-
-
+ )} +
); } diff --git a/src/components/molecules/ChatBox/ChatBox.tsx b/src/components/molecules/ChatBox/ChatBox.tsx index 984bd80..0a80dfe 100755 --- a/src/components/molecules/ChatBox/ChatBox.tsx +++ b/src/components/molecules/ChatBox/ChatBox.tsx @@ -1,34 +1,39 @@ import React, { - FormEvent, - useCallback, - useEffect, + useRef, useMemo, useState, + useEffect, + FormEvent, + useCallback, } from 'react'; -import clsx from 'clsx'; -import moment from 'moment'; -import { LuSend } from 'react-icons/lu'; -import InputEmoji from 'react-input-emoji' -import { useAppDispatch, useAppSelector, useAxiosPrivate } from '../../../app'; -import { PrivateRequestConstruct } from '../../../app/features/requests'; import { - IChatBoxProps, - IChatViewProps, + IUser, IMessage, IMessageProps, - IUser, + IChatBoxProps, + IChatViewProps, } from '../../../../typings'; +import clsx from 'clsx'; +import moment from 'moment'; +import { LuSend } from 'react-icons/lu'; +import InputEmoji from 'react-input-emoji' import { addMessages } from '../../../app/slices/messagesSlice'; import { updateNewMessage } from '../../../app/slices/socketSlice'; +import { useAppDispatch, useAppSelector, useAxiosPrivate } from '../../../app'; +import { MessageRequests, UserRequests } from '../../../app/features/requests'; const ChatBox: React.FC<{ props: IChatBoxProps }> = ({ props, }) => { - const { currentChat, user } = props; const dispatch = useAppDispatch() + const { currentChat, user } = props; const axiosInstance = useAxiosPrivate(); - const privateRequestInstance = useMemo( - () => new PrivateRequestConstruct(axiosInstance), + const messageRequests = useMemo( + () => new MessageRequests(axiosInstance), + [axiosInstance] + ); + const userRequests = useMemo( + () => new UserRequests(axiosInstance), [axiosInstance] ); const reduxMessages = useAppSelector((state) => state.messageReduce.messages); @@ -49,7 +54,7 @@ const ChatBox: React.FC<{ props: IChatBoxProps }> = ({ return; } - const fetch = await privateRequestInstance.useGetChatMessages(currentChat._id); + const fetch = await messageRequests.useGetChatMessages(currentChat._id); if (fetch.success) { dispatch( @@ -63,11 +68,11 @@ const ChatBox: React.FC<{ props: IChatBoxProps }> = ({ } catch (err) { console.log(err); } - }, [currentChat._id, dispatch, privateRequestInstance]); + }, [currentChat._id, dispatch, messageRequests]); const getRecipientUser = useCallback(async () => { try { - const fetch = await privateRequestInstance.useGetRecipientUserQuery( + const fetch = await userRequests.useGetRecipientUserQuery( currentChat?.members as string[], user?._id as string ); @@ -81,7 +86,7 @@ const ChatBox: React.FC<{ props: IChatBoxProps }> = ({ } catch (err) { console.log(err); } - }, [currentChat, privateRequestInstance, user]); + }, [currentChat?.members, user?._id, userRequests]); useEffect(() => { if (currentChat && currentChat.members && currentChat.members.length > 0) { @@ -118,8 +123,8 @@ const ChatBox: React.FC<{ props: IChatBoxProps }> = ({ ); }; -const Message: React.FC<{ props: IMessageProps }> = ({ props }) => { - const { message, prevMessage, isMainUserMessage } = props; +const Message: React.FC<{ props: IMessageProps }> = ({ props }) => { + const { message, prevMessage, isMainUserMessage, ref } = props; const [previousMessage, setPreviousMessage] = useState( undefined ); @@ -132,6 +137,7 @@ const Message: React.FC<{ props: IMessageProps }> = ({ props }) => { return (
= ({ }): JSX.Element => { const { messages, userId, chatId, recipientUser } = props; + const scrollRef = useRef(null); + const dispatch = useAppDispatch(); const axiosInstance = useAxiosPrivate(); - const privateRequestInstance = useMemo( - () => new PrivateRequestConstruct(axiosInstance), + const messageRequests = useMemo( + () => new MessageRequests(axiosInstance), [axiosInstance] ); const allMessages = useAppSelector((state) => state.messageReduce.messages); const [newMessage, setNewMessage] = useState(''); + const [messageIsSending, setMessageIsSending] = useState(false); const handleMessageFormPost = async (e: FormEvent) => { try { e.preventDefault(); if (newMessage === '') return; + setMessageIsSending(true); + const msg = newMessage; + setNewMessage(''); const newMessageData: { chatId: string; senderId: string; text: string } = { chatId, senderId: userId, - text: newMessage, + text: msg, }; - const fetch = await privateRequestInstance.usePostChatMessages(newMessageData); + const fetch = await messageRequests.usePostChatMessages(newMessageData); const newMessageToAdd = fetch.data as IMessage; dispatch(addMessages({ messages: [...allMessages, newMessageToAdd] })); dispatch(updateNewMessage({ newMessage: newMessageToAdd })); - setNewMessage(''); } catch (err) { console.log(err); + } finally { + setMessageIsSending(false); } }; + useEffect(() => { + scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + return (
{/* Display recipient user name */} @@ -237,7 +254,7 @@ const ChatView: React.FC<{ props: IChatViewProps }> = ({
{/* Chat Display */} -
+
{messages.map((message, index) => { const previousMessage = messages[index - 1] ?? undefined; @@ -255,6 +272,7 @@ const ChatView: React.FC<{ props: IChatViewProps }> = ({ > = ({
); })} - {/*
*/}
@@ -273,19 +290,29 @@ const ChatView: React.FC<{ props: IChatViewProps }> = ({
diff --git a/src/components/molecules/PotentialChat/PotentialChatWrap.tsx b/src/components/molecules/PotentialChat/PotentialChatWrap.tsx index 5a687b9..422b04f 100755 --- a/src/components/molecules/PotentialChat/PotentialChatWrap.tsx +++ b/src/components/molecules/PotentialChat/PotentialChatWrap.tsx @@ -2,15 +2,15 @@ import React, { useMemo } from 'react'; import { IPotentialChatWrapProps } from '../../../../typings'; import PotentialChat from './PotentialChat'; import { useAppSelector, useAxiosPrivate } from '../../../app'; -import { PrivateRequestConstruct } from '../../../app/features/requests'; +import { ChatRequests } from '../../../app/features/requests'; const PotentialChatWrap: React.FC<{ props: IPotentialChatWrapProps, updatePotentialChatsCb?: () => void }> = ({ props, updatePotentialChatsCb }): JSX.Element => { const axiosInstance = useAxiosPrivate(); - const privateRequestInstance = useMemo( - () => new PrivateRequestConstruct(axiosInstance), + const chatRequests = useMemo( + () => new ChatRequests(axiosInstance), [axiosInstance] ); @@ -18,7 +18,7 @@ const PotentialChatWrap: React.FC<{ props: IPotentialChatWrapProps, updatePotent const createChatProcess = (id: string) => { const createChatObj = { userOneId: user._id as string, userTwoId: id }; - const fetch = privateRequestInstance.useCreateChatMutation(createChatObj); + const fetch = chatRequests.useCreateChatMutation(createChatObj); fetch .then(() => { diff --git a/src/components/molecules/UserChat/UserChat.tsx b/src/components/molecules/UserChat/UserChat.tsx index bee5c79..6b7a247 100755 --- a/src/components/molecules/UserChat/UserChat.tsx +++ b/src/components/molecules/UserChat/UserChat.tsx @@ -1,15 +1,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { IUser, IUserChatProps } from '../../../../typings'; import { useAppSelector, useAxiosPrivate } from '../../../app'; -import { PrivateRequestConstruct } from '../../../app/features/requests'; +import { UserRequests } from '../../../app/features/requests'; import clsx from 'clsx'; const UserChat: React.FC<{ props: IUserChatProps }> = ({ props: { user, chat }, }) => { const axiosInstance = useAxiosPrivate(); - const privateRequestInstance = useMemo( - () => new PrivateRequestConstruct(axiosInstance), + const userRequests = useMemo( + () => new UserRequests(axiosInstance), [axiosInstance] ); @@ -24,7 +24,7 @@ const UserChat: React.FC<{ props: IUserChatProps }> = ({ const recipientId = chat?.members?.find((id) => id !== user?._id); if (!recipientId) return null; - const fetch = privateRequestInstance.useGetUserByIdQuery(recipientId); + const fetch = userRequests.useGetUserByIdQuery(recipientId); fetch .then((res) => { if (res.success) { @@ -39,7 +39,7 @@ const UserChat: React.FC<{ props: IUserChatProps }> = ({ .finally(() => { setIsLoading(false); }); - }, [chat?.members, privateRequestInstance, user?._id]); + }, [chat?.members, user?._id, userRequests]); useEffect(() => { getRecipientUserProcess(); diff --git a/src/components/sections/NavBar.tsx b/src/components/sections/NavBar.tsx index b0a5c09..8d212cc 100755 --- a/src/components/sections/NavBar.tsx +++ b/src/components/sections/NavBar.tsx @@ -1,35 +1,38 @@ -import { Fragment, useEffect, useState } from 'react' -import { Disclosure, Menu, Transition } from '@headlessui/react' -import { - Bars3Icon, - BellIcon, - XMarkIcon, -} from '@heroicons/react/24/outline' +import moment from 'moment'; import { useLocation, Link } from 'react-router-dom'; -import { INavBarProps, IUser } from '../../../typings'; import useLogOutUser from '../../app/hooks/useLogOutUser'; +import { useAppDispatch, useAppSelector } from '../../app'; +import { updateCurrentChat } from '../../app/slices/chatSlice'; +import { Disclosure, Menu, Transition } from '@headlessui/react'; +import { Fragment, useCallback, useEffect, useState } from 'react'; +import { setReduxNotifications } from '../../app/slices/notificationSlice'; +import { IChat, INavBarProps, INotification, IUser } from '../../../typings'; +import { Bars3Icon, BellIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { UnreadNotificationsFunc } from '../../util/manipulate-notification'; function classNames(...classes: string[]) { - return classes.filter(Boolean).join(' ') + return classes.filter(Boolean).join(' '); } const NavBar: React.FC<{ props: INavBarProps }> = ({ props: { user } }) => { const location = useLocation(); const logOut = useLogOutUser(); - const [localUser, setLocalUser] = useState({}) + const [localUser, setLocalUser] = useState({}); const [userExists, setUserExists] = useState(false); const [route, setRoute] = useState(''); - const [userNavigationList, setUserNavigationList] = useState<{ - id: number; - name: string; - action: () => void - }[]>([]); + const [userNavigationList, setUserNavigationList] = useState< + { + id: number; + name: string; + action: () => void; + }[] + >([]); const sendLocation = (value: string) => { window.location.assign(value); - } + }; useEffect(() => { // Set Route @@ -68,6 +71,7 @@ const NavBar: React.FC<{ props: INavBarProps }> = ({ props: { user } }) => { setLocalUser({}); setUserExists(false); } + // Don't add the logOut variable as a dependency }, [location, user]); return ( @@ -87,7 +91,10 @@ const NavBar: React.FC<{ props: INavBarProps }> = ({ props: { user } }) => { {/* */} {userExists ? ( - + ) : (
= ({ props: { user } }) => {
{userNavigationList.map((item) => ( {item.name} @@ -171,27 +178,177 @@ const NavBar: React.FC<{ props: INavBarProps }> = ({ props: { user } }) => { )} )} - {' '} +
); -} +}; + +function LgMenuComponent({ + localUser, + userNavigationList, +}: { + localUser: IUser; + userNavigationList: { + id: number; + name: string; + action: () => void; + }[]; +}) { + const dispatch = useAppDispatch(); + const [unreadNotifications, setUnreadNotifications] = useState< + INotification[] + >([]); + + const notifications = useAppSelector( + (state) => state.notificationReduce.notifications + ); + + useEffect(() => { + const unreadNotificationList = UnreadNotificationsFunc(notifications); + setUnreadNotifications(unreadNotificationList); + }, [notifications]); + + const markAllNotificationsAsRead = useCallback((notifications: INotification[]) => { + const mNotifications = notifications.map((notif) => { + return { ...notif, isRead: true }; + }) + + dispatch( + setReduxNotifications({ notifications: mNotifications }) + ) + }, [dispatch]); + + const markNotificationAsRead = useCallback( + ( + notification: INotification, + userChats: IChat[], + user: IUser, + notifications: INotification[] + ) => { + const chatToOpen = userChats.find((chat) => { + const chatMembers = [user._id, notification.senderId._id]; + const isChatToOpen = chat.members?.every((member) => { + return chatMembers.includes(member); + }); + + return isChatToOpen; + }) + + // Modified Clicked notification to isRead = true + const mNotifications = notifications.map((el) => { + if (notification.senderId === el.senderId) { + return { + ...notification, + isRead: true, + }; + } else { + return el; + } + }) + + // Update Modified notification list + dispatch( + setReduxNotifications({ + notifications: mNotifications, + }) + ); -function LgMenuComponent({ localUser, userNavigationList }: { localUser: IUser, userNavigationList: { - id: number; - name: string; - action: () => void - }[] }) { + if (!chatToOpen) return; + dispatch( + updateCurrentChat({ + chat: chatToOpen, + }) + ); + }, + [dispatch] + ); + + const user = useAppSelector((state) => state.userReduce); + const userChats = useAppSelector((state) => state.userChatsReduce.chats); + return (
- + {/* Notification Dropdown */} + +
+ + + View notifications + {unreadNotifications.length > 0 && ( +
+ + {unreadNotifications.length} + +
+ )} +
+
+ + + +
+

+ Notifications +

+ + +
+
+ + {/* Handle notification list */} + {notifications.map((notification, index) => ( + + markNotificationAsRead(notification, userChats, user, notifications )} + className={classNames( + notification.isRead ? '' : 'bg-gray-100', + 'w-full flex flex-col px-5 py-2 text-sm text-gray-700 cursor-pointer' + )} + > +

+ + {notification.senderId.fullname} + + sent you message +

+

+ {moment(notification.date as string).calendar()} +

+
+
+ ))} + + {/* If no notification in notification list */} + {notifications.length < 1 && ( + + + + )} +
+
+
{/* Profile dropdown */} @@ -237,7 +394,7 @@ function LgMenuComponent({ localUser, userNavigationList }: { localUser: IUser,
- ) + ); } -export default NavBar \ No newline at end of file +export default NavBar; diff --git a/src/components/sections/SideBarChatList/SideBarChatList.tsx b/src/components/sections/SideBarChatList/SideBarChatList.tsx index 73105a5..e52fbf9 100755 --- a/src/components/sections/SideBarChatList/SideBarChatList.tsx +++ b/src/components/sections/SideBarChatList/SideBarChatList.tsx @@ -1,13 +1,13 @@ -import React, { useCallback, useMemo, useState } from 'react'; import { MdClose } from 'react-icons/md'; -import { useAppDispatch, useAppSelector, useAxiosPrivate } from '../../../app'; -import { setSideBarChatDisplay } from '../../../app/slices/appUIStateSlice'; -import { PotentialChatWrap, UserChatWrap } from '../../molecules'; import { IChat, IUser } from '../../../../typings'; -import { addPotentialChat } from '../../../app/slices/potentialChatsSlice'; -import { PrivateRequestConstruct } from '../../../app/features/requests'; -import { addUserChat } from '../../../app/slices/userChatsSlice'; import LoadingPlayer from '../../atoms/LoadingPlayer'; +import React, { useCallback, useMemo, useState } from 'react'; +import { addUserChat } from '../../../app/slices/userChatsSlice'; +import { PotentialChatWrap, UserChatWrap } from '../../molecules'; +import { addPotentialChat } from '../../../app/slices/potentialChatsSlice'; +import { setSideBarChatDisplay } from '../../../app/slices/appUIStateSlice'; +import { ChatRequests, UserRequests } from '../../../app/features/requests'; +import { useAppDispatch, useAppSelector, useAxiosPrivate } from '../../../app'; interface ISideBarChatListProps {} @@ -15,9 +15,14 @@ const SideBarChatList: React.FC = () => { const dispatch = useAppDispatch(); const user = useAppSelector((state) => state.userReduce); const axiosInstance = useAxiosPrivate(); - const privateRequestInstance = useMemo( - () => new PrivateRequestConstruct(axiosInstance), - [axiosInstance]) + const userRequests = useMemo( + () => new UserRequests(axiosInstance), + [axiosInstance] + ); + const chatRequests = useMemo( + () => new ChatRequests(axiosInstance), + [axiosInstance] + ); const [chatsIsLoading, setChatsIsLoading] = useState(false); const [pChatsIsLoading, setPChatsIsLoading] = useState(false); @@ -37,10 +42,10 @@ const SideBarChatList: React.FC = () => { let willEnterStepTwo = false; setChatsIsLoading(true); let allChats: Array = []; - const fetch = privateRequestInstance.useGetUserChatsQuery(id); + const fetch = chatRequests.useGetUserChatsQuery(id); return fetch - .then((res) => { + .then((res: { success: unknown; data: IChat[]; }) => { if (res.success) { allChats = res.data; @@ -65,13 +70,13 @@ const SideBarChatList: React.FC = () => { setChatsIsLoading(false); }); }, - [dispatch, privateRequestInstance] + [chatRequests, dispatch] ); const getPotentialChatsHandler = useCallback( (chats: Array) => { setPChatsIsLoading(true); - const fetch = privateRequestInstance.useGetAllUsersQuery(); + const fetch = userRequests.useGetAllUsersQuery(); fetch .then((res) => { @@ -110,7 +115,7 @@ const SideBarChatList: React.FC = () => { setPChatsIsLoading(false); }); }, - [dispatch, privateRequestInstance, user._id] + [dispatch, user._id, userRequests] ); const callBackFromPotentialChatsWrap = async () => { diff --git a/src/components/tools/AuthRouteController.tsx b/src/components/tools/AuthRouteController.tsx index de4c452..92d462c 100755 --- a/src/components/tools/AuthRouteController.tsx +++ b/src/components/tools/AuthRouteController.tsx @@ -1,16 +1,16 @@ /* eslint-disable no-console */ +import { IUser } from '../../../typings'; import { useEffect, useMemo } from 'react'; -import { setToken, useAppDispatch, useAppSelector, useAxiosPrivate } from '../../app'; -import { PrivateRequestConstruct } from '../../app/features/requests'; import { setUser } from '../../app/slices/userSlice'; -import { IUser } from '../../../typings'; +import { UserRequests} from '../../app/features/requests'; +import { setToken, useAppDispatch, useAppSelector, useAxiosPrivate } from '../../app'; export default function AuthRouteController() { const dispatch = useAppDispatch(); const authState = useAppSelector((state) => state.authReduce); const axiosInstance = useAxiosPrivate(); - const privateRequestInstance = useMemo(() => new PrivateRequestConstruct(axiosInstance), [axiosInstance]); + const userRequests = useMemo(() => new UserRequests(axiosInstance), [axiosInstance]); const sendlocation = (location: string) => { window.location.assign(location); @@ -40,7 +40,7 @@ export default function AuthRouteController() { refresh: string; }; - const fetch = privateRequestInstance.useGetUserByIdQuery(jsonifyAuthContainer._id); + const fetch = userRequests.useGetUserByIdQuery(jsonifyAuthContainer._id); fetch .then((res) => { @@ -77,7 +77,7 @@ export default function AuthRouteController() { } } } - }, [authState, dispatch, privateRequestInstance]); + }, [authState, dispatch, userRequests]); return null; } diff --git a/src/components/tools/SocketClient.tsx b/src/components/tools/SocketClient.tsx index 9ebdec0..178e5e1 100755 --- a/src/components/tools/SocketClient.tsx +++ b/src/components/tools/SocketClient.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; import { Socket, io } from 'socket.io-client'; -import { IMessage, IOnlineUser } from '../../../typings'; import { useAppDispatch, useAppSelector } from '../../app'; -import { updateOnlineUsers } from '../../app/slices/socketSlice'; import { addMessages } from '../../app/slices/messagesSlice'; +import { updateOnlineUsers } from '../../app/slices/socketSlice'; +import { IMessage, INotification, IOnlineUser } from '../../../typings'; +import { setReduxNotifications } from '../../app/slices/notificationSlice'; const SocketClient = () => { const dispatch = useAppDispatch(); @@ -16,6 +17,8 @@ const SocketClient = () => { const [socket, setSocket] = useState(undefined); const socketUri = import.meta.env.VITE_SOCKET_URL; + + const reduxNotifications = useAppSelector((state) => state.notificationReduce.notifications); useEffect(() => { if (user && user._id !== '') { @@ -33,11 +36,11 @@ const SocketClient = () => { newSocket.on('disconnect', () => { setSocket(undefined); setSocketId(undefined); - console.log('Disconnected'); + console.warn('Disconnected'); }); } } - }, [user]); + }, [socketUri, user]); // Get active users useEffect(() => { @@ -87,6 +90,45 @@ const SocketClient = () => { } }, [activeChat, allMessages, dispatch, socket]); + // Receive and Handle Notifications + useEffect(() => { + if (socket) { + const handleGetNotification = (res: INotification) => { + const isChatOpen = activeChat.members?.some( + (id) => id === res.senderId._id + ); + + if (isChatOpen) { + let newNotifications: INotification[] = []; + const getNewNotifications = (prev: INotification[]) => { + newNotifications = [{ ...res, isRead: true }, ...prev]; + dispatch( + setReduxNotifications({ notifications: newNotifications }) + ) + return newNotifications; + } + getNewNotifications(reduxNotifications); + } else { + let newNotifications: INotification[] = []; + const getNewNotifications = (prev: INotification[]) => { + newNotifications = [res, ...prev]; + dispatch( + setReduxNotifications({ notifications: newNotifications }) + ); + return newNotifications; + } + getNewNotifications(reduxNotifications); + } + } + + socket.on('get-notification', handleGetNotification); + + return () => { + socket.off('get-notification', handleGetNotification); + } + } + }, [activeChat, dispatch, reduxNotifications, socket]); + return null; }; diff --git a/src/util/manipulate-notification.ts b/src/util/manipulate-notification.ts new file mode 100644 index 0000000..aa3f824 --- /dev/null +++ b/src/util/manipulate-notification.ts @@ -0,0 +1,7 @@ +import { INotification } from '../../typings'; + +function UnreadNotificationsFunc(notifications: INotification[]) { + return notifications.filter((notif) => notif.isRead === false); +} + +export { UnreadNotificationsFunc } \ No newline at end of file diff --git a/src/views/Chat.tsx b/src/views/Chat.tsx index ddd74b1..05e5ab2 100755 --- a/src/views/Chat.tsx +++ b/src/views/Chat.tsx @@ -3,7 +3,7 @@ import { ChatSection } from '../components/sections'; import { IChat, IUser, PageProps } from '../../typings'; import { addUserChat } from '../app/slices/userChatsSlice'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { PrivateRequestConstruct } from '../app/features/requests'; +import { ChatRequests, UserRequests } from '../app/features/requests'; import { addPotentialChat } from '../app/slices/potentialChatsSlice'; import { setSideBarChatDisplay } from '../app/slices/appUIStateSlice'; import { useAppDispatch, useAppSelector, useAxiosPrivate } from '../app'; @@ -11,8 +11,12 @@ import { useAppDispatch, useAppSelector, useAxiosPrivate } from '../app'; const Chat: React.FC<{ props?: PageProps }> = () => { const dispatch = useAppDispatch(); const axiosInstance = useAxiosPrivate(); - const privateRequestInstance = useMemo( - () => new PrivateRequestConstruct(axiosInstance), + const chatRequests = useMemo( + () => new ChatRequests(axiosInstance), + [axiosInstance] + ); + const userRequests = useMemo( + () => new UserRequests(axiosInstance), [axiosInstance] ); @@ -60,7 +64,7 @@ const Chat: React.FC<{ props?: PageProps }> = () => { let willEnterStepTwo = false; setChatsIsLoading(true); let allChats: Array = []; - const response = (await privateRequestInstance.useGetUserChatsQuery( + const response = (await chatRequests.useGetUserChatsQuery( id )) as { success: boolean; data: IChat[] }; @@ -86,18 +90,17 @@ const Chat: React.FC<{ props?: PageProps }> = () => { setChatsIsLoading(false); } }, - [dispatch, privateRequestInstance] + [chatRequests, dispatch] ); const getPotentialChatsHandler = useCallback( async (chats: Array) => { try { setPChatsIsLoading(true); - const response = - (await privateRequestInstance.useGetAllUsersQuery()) as { - success: boolean; - data: IUser[]; - }; + const response = (await userRequests.useGetAllUsersQuery()) as { + success: boolean; + data: IUser[]; + }; if (response.success) { const pChats = response.data; @@ -128,7 +131,7 @@ const Chat: React.FC<{ props?: PageProps }> = () => { setPChatsIsLoading(false); } }, - [dispatch, privateRequestInstance, user._id] + [dispatch, user._id, userRequests] ); const callBackFromPotentialChatsWrap = async () => { diff --git a/tailwind.config.js b/tailwind.config.js index a1e0ef5..6aa9cf7 100755 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,6 +9,9 @@ export default { extend: { fontFamily: { 'nunito': ['Nunito'] + }, + colors: { + 'primary-purple': '#1E293B', } }, }, diff --git a/typings.ts b/typings.ts index 20c0969..f2ca3a0 100755 --- a/typings.ts +++ b/typings.ts @@ -87,13 +87,21 @@ export interface IChatViewProps { recipientUser: IUser; } -export interface IMessageProps { +export interface IMessageProps { prevMessage?: IMessage; message: IMessage; isMainUserMessage: boolean; + ref?: React.LegacyRef } export interface IOnlineUser { userId: string; socketId: string } + +export interface INotification { + date: unknown; + senderId: IUser; + message: string; + isRead: boolean; +} \ No newline at end of file From ce9dddaa61f62bc09cb437fd76501aaefe9564f8 Mon Sep 17 00:00:00 2001 From: harrylever Date: Wed, 10 Apr 2024 13:59:45 +0100 Subject: [PATCH 2/2] feat: Chat last message --- src/app/features/requests.ts | 7 + src/app/index.ts | 2 +- .../molecules/UserChat/UserChat.tsx | 164 ++++++++++++++++-- .../molecules/UserChat/UserChatWrap.tsx | 29 +--- src/components/tools/index.ts | 3 +- .../tools/useFetchLatestMessage.tsx | 35 ++++ 6 files changed, 194 insertions(+), 46 deletions(-) create mode 100644 src/components/tools/useFetchLatestMessage.tsx diff --git a/src/app/features/requests.ts b/src/app/features/requests.ts index 0460752..796549e 100755 --- a/src/app/features/requests.ts +++ b/src/app/features/requests.ts @@ -87,6 +87,13 @@ export class MessageRequests { }); return fetch.data; } + + async useGetLastChatMessage(chatId: string) { + const fetch = await this.axiosInstance.get( + `/messages/chat/${chatId}/last-message` + ); + return fetch.data; + } } export class NotificationRequests { diff --git a/src/app/index.ts b/src/app/index.ts index d5f112c..c4b42e1 100755 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -1,5 +1,5 @@ export * from './hooks/hooks'; +export * from './store/store'; export * from './constants/const'; export * from './slices/authSlice'; -export * from './store/store'; export { default as useAxiosPrivate } from './hooks/useAxiosPrivate'; \ No newline at end of file diff --git a/src/components/molecules/UserChat/UserChat.tsx b/src/components/molecules/UserChat/UserChat.tsx index 6b7a247..d6834f1 100755 --- a/src/components/molecules/UserChat/UserChat.tsx +++ b/src/components/molecules/UserChat/UserChat.tsx @@ -1,21 +1,43 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { IUser, IUserChatProps } from '../../../../typings'; -import { useAppSelector, useAxiosPrivate } from '../../../app'; -import { UserRequests } from '../../../app/features/requests'; import clsx from 'clsx'; +import moment from 'moment'; +import { FetchLatestMessage } from '../../tools'; +import { UserRequests } from '../../../app/features/requests'; +import { updateCurrentChat } from '../../../app/slices/chatSlice'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { setSideBarChatDisplay } from '../../../app/slices/appUIStateSlice'; +import { setReduxNotifications } from '../../../app/slices/notificationSlice'; +import { useAppDispatch, useAppSelector, useAxiosPrivate } from '../../../app'; +import { UnreadNotificationsFunc } from '../../../util/manipulate-notification'; +import { + IChat, + INotification, + IUser, + IUserChatProps, +} from '../../../../typings'; const UserChat: React.FC<{ props: IUserChatProps }> = ({ props: { user, chat }, }) => { + const dispatch = useAppDispatch(); const axiosInstance = useAxiosPrivate(); const userRequests = useMemo( () => new UserRequests(axiosInstance), [axiosInstance] ); + const sideBarChatListIsOpen = useAppSelector( + (state) => state.appUIStateReduce.sideBarChatOpen + ); + const [recipientUser, setRecipientUser] = useState( undefined ); + const [unReadNotifications, setUnReadNotifications] = useState< + INotification[] + >([]); + const [thisUserNotifications, setThisUserNotifications] = useState< + INotification[] + >([]); const [, setIsLoading] = useState(false); @@ -50,18 +72,112 @@ const UserChat: React.FC<{ props: IUserChatProps }> = ({ onlineUsers && onlineUsers.some((user) => user.userId === recipientUser?._id); + const notifications = useAppSelector( + (state) => state.notificationReduce.notifications + ); + + // Update all unread messages + useEffect(() => { + const newUnReadNotifications = UnreadNotificationsFunc(notifications); + setUnReadNotifications(newUnReadNotifications); + }, [notifications]); + + // Update User Notification for Specific Sender + useEffect(() => { + const newThisUserNotifications = unReadNotifications.filter( + (notif) => notif.senderId._id === recipientUser?._id + ); + setThisUserNotifications(newThisUserNotifications); + }, [recipientUser?._id, unReadNotifications]); + + // Mark All Notifications for Specific sender + const markThisUserNotfications = useCallback( + ( + notificationsParam: INotification[], + thisUserNotificationsParam: INotification[] + ) => { + const mNotifications = notificationsParam.map((el) => { + let notification: INotification | undefined = undefined; + + thisUserNotificationsParam.forEach((notif) => { + if (notif.senderId._id === el.senderId._id) { + notification = { + ...notif, + isRead: true, + }; + } else { + notification = el; + } + }); + + return notification as unknown as INotification; + }); + + dispatch( + setReduxNotifications({ + notifications: mNotifications, + }) + ); + }, + [dispatch] + ); + + // Update the current chat reducer + const updateCurrentChatHandler = (chat: IChat) => { + dispatch( + updateCurrentChat({ + chat, + }) + ); + + if (thisUserNotifications.length > 0) { + markThisUserNotfications(notifications, thisUserNotifications); + } + + if (sideBarChatListIsOpen) { + setTimeout(() => { + dispatch( + setSideBarChatDisplay({ + sideBarChatOpen: false, + }) + ); + }, 500); + } + }; + + const latestMessage = FetchLatestMessage(chat as IChat); + + const truncateText = (text: string) => { + let shortText = text.substring(0, 20); + + if (text.length > 20) { + shortText = shortText + '...'; + } + return shortText; + }; + return ( ); diff --git a/src/components/molecules/UserChat/UserChatWrap.tsx b/src/components/molecules/UserChat/UserChatWrap.tsx index e2ba8d2..530b74a 100755 --- a/src/components/molecules/UserChat/UserChatWrap.tsx +++ b/src/components/molecules/UserChat/UserChatWrap.tsx @@ -1,40 +1,15 @@ import React from 'react'; import UserChat from './UserChat'; -import { useAppDispatch, useAppSelector } from '../../../app'; -import { IChat, IUserChatWrapProps } from '../../../../typings'; -import { updateCurrentChat } from '../../../app/slices/chatSlice'; -import { setSideBarChatDisplay } from '../../../app/slices/appUIStateSlice'; +import { IUserChatWrapProps } from '../../../../typings'; const UserChatWrap: React.FC<{ props: IUserChatWrapProps; }> = ({ props: { chats, user } }) => { - const dispatch = useAppDispatch(); - - const sideBarChatListIsOpen = useAppSelector((state) => state.appUIStateReduce.sideBarChatOpen); - - // Update the current chat reducer - const updateCurrentChatHandler = (chat: IChat) => { - dispatch( - updateCurrentChat({ - chat, - }) - ); - - if (sideBarChatListIsOpen) { - setTimeout(() => { - dispatch( - setSideBarChatDisplay({ - sideBarChatOpen: false, - }) - ); - }, 500); - } - }; return (
{chats.map((chat, _) => ( -
updateCurrentChatHandler(chat)}> +
))} diff --git a/src/components/tools/index.ts b/src/components/tools/index.ts index c1b0a39..26ae3d8 100755 --- a/src/components/tools/index.ts +++ b/src/components/tools/index.ts @@ -1,2 +1,3 @@ +export { default as SocketClient } from './SocketClient'; export { default as AuthRouteController } from './AuthRouteController'; -export { default as SocketClient } from './SocketClient'; \ No newline at end of file +export { default as FetchLatestMessage } from './useFetchLatestMessage'; \ No newline at end of file diff --git a/src/components/tools/useFetchLatestMessage.tsx b/src/components/tools/useFetchLatestMessage.tsx new file mode 100644 index 0000000..95aff9a --- /dev/null +++ b/src/components/tools/useFetchLatestMessage.tsx @@ -0,0 +1,35 @@ +import { IChat, IMessage } from '../../../typings'; +import { useAppSelector, useAxiosPrivate } from '../../app'; +import { MessageRequests } from '../../app/features/requests'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +const FetchLatestMessage = (chat: IChat): IMessage => { + const axiosInstance = useAxiosPrivate(); + const messageRequests = useMemo( + () => new MessageRequests(axiosInstance), + [axiosInstance] + ); + const newMessage = useAppSelector((state) => state.socketReduce.newMessage); + const notifications = useAppSelector( + (state) => state.notificationReduce.notifications + ); + const [latestMessage, setLatestMessage] = useState( + undefined + ); + + const getMessages = useCallback(async () => { + if (!chat._id) return; + const response = (await messageRequests.useGetLastChatMessage( + chat._id as string + )) as { success: boolean; data: IMessage }; + setLatestMessage(response.data); + }, [chat._id, messageRequests]); + + useEffect(() => { + getMessages(); + }, [getMessages, newMessage, notifications]); + + return latestMessage as IMessage; +}; + +export default FetchLatestMessage;