From be418a18aa99129ae27767d09dd6025e3ed2abaa Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Tue, 1 Mar 2022 22:11:56 +0300 Subject: [PATCH] feat: rendering notifications --- src/App.tsx | 15 ++++ src/components/Chat/Sidebar/Heading.tsx | 4 +- .../Chat/Sidebar/Users/User/Info.tsx | 12 ++-- src/components/Chat/Sidebar/Users/Users.tsx | 40 +++++------ src/components/Header/Notifications.tsx | 69 +++++++++++++++++-- src/contexts/index.ts | 3 +- .../queries/chat/useFetchConversations.ts | 25 +++++++ src/hooks/queries/index.ts | 6 ++ src/interfaces/index.ts | 3 - src/services/ChatService.ts | 24 +++++++ src/stores/UserStore.ts | 69 +++++++------------ src/types/index.d.ts | 7 ++ 12 files changed, 195 insertions(+), 82 deletions(-) create mode 100644 src/hooks/queries/chat/useFetchConversations.ts create mode 100644 src/services/ChatService.ts diff --git a/src/App.tsx b/src/App.tsx index edfff79..ce78a65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { useToast } from '@chakra-ui/react'; const App = () => { const { + userStore, uiStore: { history }, onlineStore, } = useRootStore(); @@ -56,6 +57,20 @@ const App = () => { status: 'info', duration: 5000, }); + + if (!window.location.pathname.includes('messages')) { + /** + * Update user store + */ + if (data.notificationId) { + userStore.addNotification({ + id: data.notificationId, + content: 'You received a new private message', + time: new Date().toISOString(), + type: 'MESSAGE', + }); + } + } } }); useSocketEvent('user:online', online => onlineStore.loadOnline(online)); diff --git a/src/components/Chat/Sidebar/Heading.tsx b/src/components/Chat/Sidebar/Heading.tsx index d752efa..451c916 100644 --- a/src/components/Chat/Sidebar/Heading.tsx +++ b/src/components/Chat/Sidebar/Heading.tsx @@ -39,9 +39,9 @@ const Heading: React.FC = props => { - + */} ); diff --git a/src/components/Chat/Sidebar/Users/User/Info.tsx b/src/components/Chat/Sidebar/Users/User/Info.tsx index 90dc789..b8fca1e 100644 --- a/src/components/Chat/Sidebar/Users/User/Info.tsx +++ b/src/components/Chat/Sidebar/Users/User/Info.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { Box, Text } from '@chakra-ui/react'; import { useRootStore } from '@educt/hooks/useRootStore'; +import { HistoryMessageType } from '@educt/types'; export interface IInfoProps { fullname: string; - lastMessage: string; + lastMessage?: HistoryMessageType | undefined; } const Info: React.FC = ({ fullname, lastMessage }) => { @@ -17,9 +18,12 @@ const Info: React.FC = ({ fullname, lastMessage }) => { {isMe ? 'Favorites' : fullname}
- - {lastMessage} - + {lastMessage && ( + + {lastMessage.from === me?.id && `You: `} + {lastMessage.content} + + )}
); diff --git a/src/components/Chat/Sidebar/Users/Users.tsx b/src/components/Chat/Sidebar/Users/Users.tsx index 6f7e9f6..040b53b 100644 --- a/src/components/Chat/Sidebar/Users/Users.tsx +++ b/src/components/Chat/Sidebar/Users/Users.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { observer } from 'mobx-react'; -import { Box, Stack, StackProps, Text } from '@chakra-ui/react'; +import { Stack, StackProps } from '@chakra-ui/react'; import { User } from './User'; import { useChatContext } from '../../context'; import { useRootStore } from '@educt/hooks/useRootStore'; import { Skeleton } from '@chakra-ui/react'; -import { useFetchUsers } from '@educt/hooks/queries'; +import { useFetchConversations } from '@educt/hooks/queries'; const Wrapper: React.FC = ({ children }) => { return ( @@ -16,14 +16,14 @@ const Wrapper: React.FC = ({ children }) => { }; const Users: React.FC = observer(() => { - const { data: users } = useFetchUsers(); const { userStore: { me }, onlineStore, } = useRootStore(); + const { data: conversations } = useFetchConversations(); const { search } = useChatContext(); - if (users === null || me === null) + if (conversations === null || me === null) return ( @@ -31,9 +31,11 @@ const Users: React.FC = observer(() => { ); - const filtered = (!search ? users : users.filter(u => u.fullname.toLocaleLowerCase().includes(search))) + const filtered = ( + !search ? conversations : conversations.filter(u => u.fullname.toLocaleLowerCase().includes(search)) + ) .concat() - .sort(u => (onlineStore.isOnline(u.id) ? -1 : 1)); + .sort((c1, c2) => new Date(c2.lastMessage.time).valueOf() - new Date(c1.lastMessage.time).valueOf()); const isEmpty = filtered.length === 0; @@ -42,27 +44,23 @@ const Users: React.FC = observer(() => { {/* Favorites */} - + {/* User list */} - {!isEmpty ? ( + {!isEmpty && filtered.map( - user => - user.id !== me.id && ( - - - + c => + c.userId !== me.id && ( + + + + ) - ) - ) : ( - - - Cannot find any users - - - )} + )} ); }); diff --git a/src/components/Header/Notifications.tsx b/src/components/Header/Notifications.tsx index 2bf4c9b..47957ff 100644 --- a/src/components/Header/Notifications.tsx +++ b/src/components/Header/Notifications.tsx @@ -1,6 +1,17 @@ -import React from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import moment from 'moment'; -import { Menu, MenuButton, MenuList, MenuItem, MenuGroup, IconButton, MenuDivider, Flex, Text } from '@chakra-ui/react'; +import { + Menu, + MenuButton, + MenuList, + MenuItem, + MenuGroup, + IconButton, + MenuDivider, + Flex, + Text, + useDisclosure, +} from '@chakra-ui/react'; import { Skeleton } from '@chakra-ui/skeleton'; import { MdNotifications, MdOutlineMessage } from 'react-icons/md'; import { observer } from 'mobx-react'; @@ -10,26 +21,69 @@ import { observer } from 'mobx-react'; */ import { useHistory } from 'react-router-dom'; import { useRootStore } from '@educt/hooks/useRootStore'; +import { SocketContext } from '@educt/contexts'; +import { runInAction } from 'mobx'; const Notifications: React.FC = observer(() => { const { - userStore: { me }, + userStore: { me, isLoading }, } = useRootStore(); const history = useHistory(); + const { socket } = useContext(SocketContext); + const { onOpen, onClose, isOpen } = useDisclosure(); + const [count, setCount] = useState(0); + + /** + * Load count of notifications + */ + useEffect(() => { + if (me) { + setCount(me.notifications.length); + } + }, [me, me?.notifications.length]); + + /** + * Mark notifications as read + */ + useEffect(() => { + if (isOpen && count > 0 && me) { + const messages = me.notifications.filter(n => n.type === 'MESSAGE'); + socket?.emit('notification:read', { + userId: me.id, + ids: messages.map(n => n.id), + }); + setCount(0); + } + }, [isOpen]); + + /** + * Delete notifications with type `message` when on message page + */ + useEffect(() => { + if (window.location.pathname.includes('messages') && me) { + socket?.emit('notification:read', { + userId: me.id, + ids: me.notifications.filter(n => n.type === 'MESSAGE').map(n => n.id), + }); + + setCount(0); + runInAction(() => { + me.notifications = me.notifications.filter(n => n.type !== 'MESSAGE'); + }); + } + }, [me]); if (me === null) { return ; } - const count = me.notifications.length; - /** * Filter types of notifications */ const messages = me.notifications.filter(notification => notification.type === 'MESSAGE'); return ( - + { icon={} _after={{ content: `"${count && count > 0 && count}"`, - display: `${count && count > 0 ? 'block' : 'none'}`, + display: `${!isLoading ? (count && count > 0 ? 'block' : 'none') : 'none'}`, position: 'absolute', top: '5px', right: '5px', @@ -52,6 +106,7 @@ const Notifications: React.FC = observer(() => { 0 ? `${count} new notifications` : 'Notifications'}> + {/* MESSAGES */} {messages.length !== 0 && ( history.push('/messages')} py='3' icon={}> diff --git a/src/contexts/index.ts b/src/contexts/index.ts index c158cf3..3b545c7 100644 --- a/src/contexts/index.ts +++ b/src/contexts/index.ts @@ -2,11 +2,10 @@ import { createContext } from 'react'; import { ChangeEmailPageContextType, CoursesPageContextType, UsersPageContextType } from '@educt/types'; import RootStore from '../stores/RootStore'; import { Socket } from 'socket.io-client'; -import { ClientToServerEvents, ServerToClientEvents } from '@educt/interfaces'; export const StoreContext = createContext(undefined); -export const SocketContext = createContext<{ socket: Socket | null }>({ +export const SocketContext = createContext<{ socket: Socket | null }>({ socket: null, }); diff --git a/src/hooks/queries/chat/useFetchConversations.ts b/src/hooks/queries/chat/useFetchConversations.ts new file mode 100644 index 0000000..394dd63 --- /dev/null +++ b/src/hooks/queries/chat/useFetchConversations.ts @@ -0,0 +1,25 @@ +import useAsync from '@educt/hooks/useAsync'; +import { ChatServiceInstance } from '@educt/services/ChatService'; +import { ConversationType } from '@educt/types'; +import { useEffect } from 'react'; + +const useFetchConversations = () => { + const fetch = async () => { + try { + const result = await ChatServiceInstance.fetchConversations(); + return result.data; + } catch (error) { + return Promise.reject(error); + } + }; + + const { execute: fetchConversations, ...state } = useAsync>(fetch); + + useEffect(() => { + fetchConversations(); + }, []); + + return { ...state }; +}; + +export { useFetchConversations }; diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts index 2f8dced..325df9a 100644 --- a/src/hooks/queries/index.ts +++ b/src/hooks/queries/index.ts @@ -46,6 +46,11 @@ import { useUpdateCategory } from './category/useUpdateCategory'; */ import { useFetchStat } from './stat/useFetchStat'; +/** + * Chat + */ +import { useFetchConversations } from './chat/useFetchConversations'; + export { useLogin, useLogout, @@ -71,4 +76,5 @@ export { useUpdateCategory, useFetchCategories, useFetchStat, + useFetchConversations, }; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 05f1cea..ee151d1 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -143,9 +143,6 @@ export interface IMe extends IUser { courses?: UserCourseType[]; likes: Array>; notifications: Array; - isAdmin: boolean; - isTeacher: boolean; - isStudent: boolean; } export interface IUserContacts { diff --git a/src/services/ChatService.ts b/src/services/ChatService.ts new file mode 100644 index 0000000..99ff0e2 --- /dev/null +++ b/src/services/ChatService.ts @@ -0,0 +1,24 @@ +import { IApiRespose } from '@educt/interfaces'; +import { ConversationType } from '@educt/types'; +import { AxiosInstance } from 'axios'; +import { ApiServiceInstance } from '.'; + +class ChatService { + public api: AxiosInstance; + + constructor(api: AxiosInstance) { + this.api = api; + } + + /** + * Fetch all conversations + * + * @returns Array of conversations + */ + public async fetchConversations(): Promise> { + const result = await this.api.get('/v1/chat/conversations'); + return result.data; + } +} + +export const ChatServiceInstance = new ChatService(ApiServiceInstance.api); diff --git a/src/stores/UserStore.ts b/src/stores/UserStore.ts index 365f98d..3462297 100644 --- a/src/stores/UserStore.ts +++ b/src/stores/UserStore.ts @@ -7,6 +7,7 @@ import * as helpers from '@educt/helpers'; import { CreateUserParamsType, FetchUsersParamsType, + NotificationType, UpdateUserContactsParamsType, UpdateUserParamsType, } from '@educt/types'; @@ -23,10 +24,12 @@ import { UserServiceInstance } from '@educt/services'; */ import RootStore from './RootStore'; +type MeMetadata = { isAdmin: boolean; isTeacher: boolean; isStudent: boolean }; + export default class UserStore { public root: RootStore; - public me: IMe | null = null; + public me: (IMe & MeMetadata) | null = null; public users: IUser[] | null = null; @@ -60,50 +63,24 @@ export default class UserStore { } public async loadCurrentUserData() { - const result = await UserServiceInstance.fetchMe(); - const { - data: { - id, - first_name, - last_name, - fullname, - about, - last_login, - email, - roles, - likes, - notifications, - contacts, - courses, - created_at, - updated_at, - }, - } = result; + try { + this.setLoading(true); + const result = await UserServiceInstance.fetchMe(); + const { data } = result; - runInAction(() => { - // TODO Fix this - this.me = { - id, - first_name, - last_name, - email, - roles, - fullname, - about, - last_login, - likes, - notifications, - contacts, - courses, - isAdmin: helpers.userContainRoles(roles, [UserRoleEnum.ADMIN]), - isTeacher: helpers.userContainRoles(roles, [UserRoleEnum.TEACHER]), - isStudent: helpers.userContainRoles(roles, [UserRoleEnum.STUDENT]), - created_at, - updated_at, - }; - }); + runInAction(() => { + this.me = { + ...data, + isAdmin: helpers.userContainRoles(data.roles, [UserRoleEnum.ADMIN]), + isTeacher: helpers.userContainRoles(data.roles, [UserRoleEnum.TEACHER]), + isStudent: helpers.userContainRoles(data.roles, [UserRoleEnum.STUDENT]), + }; + }); - return result; + return result; + } finally { + this.setLoading(false); + } } public async createUser(data: CreateUserParamsType, paramsContext?: FetchUsersParamsType) { @@ -217,4 +194,10 @@ export default class UserStore { this.me = null; }); } + + public addNotification(notification: NotificationType) { + if (this.me) { + this.me.notifications.unshift(notification); + } + } } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index bda37bd..8ab0790 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -74,6 +74,13 @@ export type HistoryMessageType = { to: string; content: string; time: string; + notificationId?: string | undefined; +}; + +export type ConversationType = { + userId: string; + fullname: string; + lastMessage: HistoryMessageType; }; export type NotificationType = {