feat: rendering notifications

This commit is contained in:
Sergey Yarkov 2022-03-01 22:11:56 +03:00
parent 981a0ef642
commit be418a18aa
12 changed files with 195 additions and 82 deletions

View File

@ -20,6 +20,7 @@ import { useToast } from '@chakra-ui/react';
const App = () => { const App = () => {
const { const {
userStore,
uiStore: { history }, uiStore: { history },
onlineStore, onlineStore,
} = useRootStore(); } = useRootStore();
@ -56,6 +57,20 @@ const App = () => {
status: 'info', status: 'info',
duration: 5000, 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<UserOnlineListType>('user:online', online => onlineStore.loadOnline(online)); useSocketEvent<UserOnlineListType>('user:online', online => onlineStore.loadOnline(online));

View File

@ -39,9 +39,9 @@ const Heading: React.FC<BoxProps> = props => {
<Button isActive variant={'ghost'} size='sm'> <Button isActive variant={'ghost'} size='sm'>
All All
</Button> </Button>
<Button colorScheme={'blue'} variant='ghost' size='sm'> {/* <Button colorScheme={'blue'} variant='ghost' size='sm'>
Unread Unread
</Button> </Button> */}
</HStack> </HStack>
</Box> </Box>
); );

View File

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { Box, Text } from '@chakra-ui/react'; import { Box, Text } from '@chakra-ui/react';
import { useRootStore } from '@educt/hooks/useRootStore'; import { useRootStore } from '@educt/hooks/useRootStore';
import { HistoryMessageType } from '@educt/types';
export interface IInfoProps { export interface IInfoProps {
fullname: string; fullname: string;
lastMessage: string; lastMessage?: HistoryMessageType | undefined;
} }
const Info: React.FC<IInfoProps> = ({ fullname, lastMessage }) => { const Info: React.FC<IInfoProps> = ({ fullname, lastMessage }) => {
@ -17,9 +18,12 @@ const Info: React.FC<IInfoProps> = ({ fullname, lastMessage }) => {
<Box ml='4'> <Box ml='4'>
<Text fontWeight={'semibold'}> <Text fontWeight={'semibold'}>
{isMe ? 'Favorites' : fullname} <br /> {isMe ? 'Favorites' : fullname} <br />
<Text as={'span'} fontWeight='normal' fontSize={'sm'} color='gray.500'> {lastMessage && (
{lastMessage} <Text as={'span'} fontWeight='normal' fontSize={'sm'} color='gray.500'>
</Text> {lastMessage.from === me?.id && `You: `}
{lastMessage.content}
</Text>
)}
</Text> </Text>
</Box> </Box>
); );

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { observer } from 'mobx-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 { User } from './User';
import { useChatContext } from '../../context'; import { useChatContext } from '../../context';
import { useRootStore } from '@educt/hooks/useRootStore'; import { useRootStore } from '@educt/hooks/useRootStore';
import { Skeleton } from '@chakra-ui/react'; import { Skeleton } from '@chakra-ui/react';
import { useFetchUsers } from '@educt/hooks/queries'; import { useFetchConversations } from '@educt/hooks/queries';
const Wrapper: React.FC<StackProps> = ({ children }) => { const Wrapper: React.FC<StackProps> = ({ children }) => {
return ( return (
@ -16,14 +16,14 @@ const Wrapper: React.FC<StackProps> = ({ children }) => {
}; };
const Users: React.FC = observer(() => { const Users: React.FC = observer(() => {
const { data: users } = useFetchUsers();
const { const {
userStore: { me }, userStore: { me },
onlineStore, onlineStore,
} = useRootStore(); } = useRootStore();
const { data: conversations } = useFetchConversations();
const { search } = useChatContext(); const { search } = useChatContext();
if (users === null || me === null) if (conversations === null || me === null)
return ( return (
<Wrapper> <Wrapper>
<Skeleton h='90px' borderRadius={'lg'} /> <Skeleton h='90px' borderRadius={'lg'} />
@ -31,9 +31,11 @@ const Users: React.FC = observer(() => {
</Wrapper> </Wrapper>
); );
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() .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; const isEmpty = filtered.length === 0;
@ -42,27 +44,23 @@ const Users: React.FC = observer(() => {
{/* Favorites */} {/* Favorites */}
<User id={me.id}> <User id={me.id}>
<User.Avatar isOnline fullname={me.fullname} /> <User.Avatar isOnline fullname={me.fullname} />
<User.Info fullname={me.fullname} lastMessage={''} /> <User.Info fullname={me.fullname} />
</User> </User>
{/* User list */} {/* User list */}
{!isEmpty ? ( {!isEmpty &&
filtered.map( filtered.map(
user => c =>
user.id !== me.id && ( c.userId !== me.id && (
<User key={user.id} id={user.id}> <User key={c.userId} id={c.userId}>
<User.Avatar isOnline={onlineStore.isOnline(user.id) || false} fullname={user.fullname} /> <User.Avatar isOnline={onlineStore.isOnline(c.userId) || false} fullname={c.fullname} />
<User.Info fullname={user.fullname} lastMessage={''} /> <User.Info fullname={c.fullname} lastMessage={c.lastMessage} />
<User.Time
time={new Date(c.lastMessage.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
/>
</User> </User>
) )
) )}
) : (
<Box textAlign={'center'}>
<Text userSelect={'none'} color='gray.500'>
Cannot find any users
</Text>
</Box>
)}
</Wrapper> </Wrapper>
); );
}); });

View File

@ -1,6 +1,17 @@
import React from 'react'; import React, { useContext, useEffect, useState } from 'react';
import moment from 'moment'; 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 { Skeleton } from '@chakra-ui/skeleton';
import { MdNotifications, MdOutlineMessage } from 'react-icons/md'; import { MdNotifications, MdOutlineMessage } from 'react-icons/md';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
@ -10,26 +21,69 @@ import { observer } from 'mobx-react';
*/ */
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useRootStore } from '@educt/hooks/useRootStore'; import { useRootStore } from '@educt/hooks/useRootStore';
import { SocketContext } from '@educt/contexts';
import { runInAction } from 'mobx';
const Notifications: React.FC = observer(() => { const Notifications: React.FC = observer(() => {
const { const {
userStore: { me }, userStore: { me, isLoading },
} = useRootStore(); } = useRootStore();
const history = useHistory(); const history = useHistory();
const { socket } = useContext(SocketContext);
const { onOpen, onClose, isOpen } = useDisclosure();
const [count, setCount] = useState<number>(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) { if (me === null) {
return <Skeleton width='50px' height='40px' mr={3} ml={3} borderRadius='md' />; return <Skeleton width='50px' height='40px' mr={3} ml={3} borderRadius='md' />;
} }
const count = me.notifications.length;
/** /**
* Filter types of notifications * Filter types of notifications
*/ */
const messages = me.notifications.filter(notification => notification.type === 'MESSAGE'); const messages = me.notifications.filter(notification => notification.type === 'MESSAGE');
return ( return (
<Menu> <Menu isOpen={isOpen} onClose={onClose} onOpen={onOpen} isLazy>
<MenuButton <MenuButton
as={IconButton} as={IconButton}
mx='2' mx='2'
@ -37,7 +91,7 @@ const Notifications: React.FC = observer(() => {
icon={<MdNotifications />} icon={<MdNotifications />}
_after={{ _after={{
content: `"${count && count > 0 && count}"`, content: `"${count && count > 0 && count}"`,
display: `${count && count > 0 ? 'block' : 'none'}`, display: `${!isLoading ? (count && count > 0 ? 'block' : 'none') : 'none'}`,
position: 'absolute', position: 'absolute',
top: '5px', top: '5px',
right: '5px', right: '5px',
@ -52,6 +106,7 @@ const Notifications: React.FC = observer(() => {
<MenuList minWidth='300px'> <MenuList minWidth='300px'>
<MenuGroup title={count && count > 0 ? `${count} new notifications` : 'Notifications'}> <MenuGroup title={count && count > 0 ? `${count} new notifications` : 'Notifications'}>
<MenuDivider /> <MenuDivider />
{/* MESSAGES */} {/* MESSAGES */}
{messages.length !== 0 && ( {messages.length !== 0 && (
<MenuItem onClick={() => history.push('/messages')} py='3' icon={<MdOutlineMessage />}> <MenuItem onClick={() => history.push('/messages')} py='3' icon={<MdOutlineMessage />}>

View File

@ -2,11 +2,10 @@ import { createContext } from 'react';
import { ChangeEmailPageContextType, CoursesPageContextType, UsersPageContextType } from '@educt/types'; import { ChangeEmailPageContextType, CoursesPageContextType, UsersPageContextType } from '@educt/types';
import RootStore from '../stores/RootStore'; import RootStore from '../stores/RootStore';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { ClientToServerEvents, ServerToClientEvents } from '@educt/interfaces';
export const StoreContext = createContext<RootStore | undefined>(undefined); export const StoreContext = createContext<RootStore | undefined>(undefined);
export const SocketContext = createContext<{ socket: Socket<ServerToClientEvents, ClientToServerEvents> | null }>({ export const SocketContext = createContext<{ socket: Socket | null }>({
socket: null, socket: null,
}); });

View File

@ -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<ConversationType[], Parameters<typeof fetch>>(fetch);
useEffect(() => {
fetchConversations();
}, []);
return { ...state };
};
export { useFetchConversations };

View File

@ -46,6 +46,11 @@ import { useUpdateCategory } from './category/useUpdateCategory';
*/ */
import { useFetchStat } from './stat/useFetchStat'; import { useFetchStat } from './stat/useFetchStat';
/**
* Chat
*/
import { useFetchConversations } from './chat/useFetchConversations';
export { export {
useLogin, useLogin,
useLogout, useLogout,
@ -71,4 +76,5 @@ export {
useUpdateCategory, useUpdateCategory,
useFetchCategories, useFetchCategories,
useFetchStat, useFetchStat,
useFetchConversations,
}; };

View File

@ -143,9 +143,6 @@ export interface IMe extends IUser {
courses?: UserCourseType[]; courses?: UserCourseType[];
likes: Array<Pick<ICourse, 'id'>>; likes: Array<Pick<ICourse, 'id'>>;
notifications: Array<NotificationType>; notifications: Array<NotificationType>;
isAdmin: boolean;
isTeacher: boolean;
isStudent: boolean;
} }
export interface IUserContacts { export interface IUserContacts {

View File

@ -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<IApiRespose<ConversationType[]>> {
const result = await this.api.get('/v1/chat/conversations');
return result.data;
}
}
export const ChatServiceInstance = new ChatService(ApiServiceInstance.api);

View File

@ -7,6 +7,7 @@ import * as helpers from '@educt/helpers';
import { import {
CreateUserParamsType, CreateUserParamsType,
FetchUsersParamsType, FetchUsersParamsType,
NotificationType,
UpdateUserContactsParamsType, UpdateUserContactsParamsType,
UpdateUserParamsType, UpdateUserParamsType,
} from '@educt/types'; } from '@educt/types';
@ -23,10 +24,12 @@ import { UserServiceInstance } from '@educt/services';
*/ */
import RootStore from './RootStore'; import RootStore from './RootStore';
type MeMetadata = { isAdmin: boolean; isTeacher: boolean; isStudent: boolean };
export default class UserStore { export default class UserStore {
public root: RootStore; public root: RootStore;
public me: IMe | null = null; public me: (IMe & MeMetadata) | null = null;
public users: IUser[] | null = null; public users: IUser[] | null = null;
@ -60,50 +63,24 @@ export default class UserStore {
} }
public async loadCurrentUserData() { public async loadCurrentUserData() {
const result = await UserServiceInstance.fetchMe(); try {
const { this.setLoading(true);
data: { const result = await UserServiceInstance.fetchMe();
id, const { data } = result;
first_name,
last_name,
fullname,
about,
last_login,
email,
roles,
likes,
notifications,
contacts,
courses,
created_at,
updated_at,
},
} = result;
runInAction(() => { runInAction(() => {
// TODO Fix this this.me = {
this.me = { ...data,
id, isAdmin: helpers.userContainRoles(data.roles, [UserRoleEnum.ADMIN]),
first_name, isTeacher: helpers.userContainRoles(data.roles, [UserRoleEnum.TEACHER]),
last_name, isStudent: helpers.userContainRoles(data.roles, [UserRoleEnum.STUDENT]),
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,
};
});
return result; return result;
} finally {
this.setLoading(false);
}
} }
public async createUser(data: CreateUserParamsType, paramsContext?: FetchUsersParamsType) { public async createUser(data: CreateUserParamsType, paramsContext?: FetchUsersParamsType) {
@ -217,4 +194,10 @@ export default class UserStore {
this.me = null; this.me = null;
}); });
} }
public addNotification(notification: NotificationType) {
if (this.me) {
this.me.notifications.unshift(notification);
}
}
} }

View File

@ -74,6 +74,13 @@ export type HistoryMessageType = {
to: string; to: string;
content: string; content: string;
time: string; time: string;
notificationId?: string | undefined;
};
export type ConversationType = {
userId: string;
fullname: string;
lastMessage: HistoryMessageType;
}; };
export type NotificationType = { export type NotificationType = {