mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 18:29:15 +03:00
feat: rendering notifications
This commit is contained in:
parent
981a0ef642
commit
be418a18aa
15
src/App.tsx
15
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<UserOnlineListType>('user:online', online => onlineStore.loadOnline(online));
|
||||
|
||||
@ -39,9 +39,9 @@ const Heading: React.FC<BoxProps> = props => {
|
||||
<Button isActive variant={'ghost'} size='sm'>
|
||||
All
|
||||
</Button>
|
||||
<Button colorScheme={'blue'} variant='ghost' size='sm'>
|
||||
{/* <Button colorScheme={'blue'} variant='ghost' size='sm'>
|
||||
Unread
|
||||
</Button>
|
||||
</Button> */}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -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<IInfoProps> = ({ fullname, lastMessage }) => {
|
||||
@ -17,9 +18,12 @@ const Info: React.FC<IInfoProps> = ({ fullname, lastMessage }) => {
|
||||
<Box ml='4'>
|
||||
<Text fontWeight={'semibold'}>
|
||||
{isMe ? 'Favorites' : fullname} <br />
|
||||
{lastMessage && (
|
||||
<Text as={'span'} fontWeight='normal' fontSize={'sm'} color='gray.500'>
|
||||
{lastMessage}
|
||||
{lastMessage.from === me?.id && `You: `}
|
||||
{lastMessage.content}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -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<StackProps> = ({ children }) => {
|
||||
return (
|
||||
@ -16,14 +16,14 @@ const Wrapper: React.FC<StackProps> = ({ 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 (
|
||||
<Wrapper>
|
||||
<Skeleton h='90px' borderRadius={'lg'} />
|
||||
@ -31,9 +31,11 @@ const Users: React.FC = observer(() => {
|
||||
</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()
|
||||
.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,26 +44,22 @@ const Users: React.FC = observer(() => {
|
||||
{/* Favorites */}
|
||||
<User id={me.id}>
|
||||
<User.Avatar isOnline fullname={me.fullname} />
|
||||
<User.Info fullname={me.fullname} lastMessage={''} />
|
||||
<User.Info fullname={me.fullname} />
|
||||
</User>
|
||||
|
||||
{/* User list */}
|
||||
{!isEmpty ? (
|
||||
{!isEmpty &&
|
||||
filtered.map(
|
||||
user =>
|
||||
user.id !== me.id && (
|
||||
<User key={user.id} id={user.id}>
|
||||
<User.Avatar isOnline={onlineStore.isOnline(user.id) || false} fullname={user.fullname} />
|
||||
<User.Info fullname={user.fullname} lastMessage={''} />
|
||||
c =>
|
||||
c.userId !== me.id && (
|
||||
<User key={c.userId} id={c.userId}>
|
||||
<User.Avatar isOnline={onlineStore.isOnline(c.userId) || false} fullname={c.fullname} />
|
||||
<User.Info fullname={c.fullname} lastMessage={c.lastMessage} />
|
||||
<User.Time
|
||||
time={new Date(c.lastMessage.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
/>
|
||||
</User>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<Box textAlign={'center'}>
|
||||
<Text userSelect={'none'} color='gray.500'>
|
||||
Cannot find any users
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
@ -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<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) {
|
||||
return <Skeleton width='50px' height='40px' mr={3} ml={3} borderRadius='md' />;
|
||||
}
|
||||
|
||||
const count = me.notifications.length;
|
||||
|
||||
/**
|
||||
* Filter types of notifications
|
||||
*/
|
||||
const messages = me.notifications.filter(notification => notification.type === 'MESSAGE');
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Menu isOpen={isOpen} onClose={onClose} onOpen={onOpen} isLazy>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
mx='2'
|
||||
@ -37,7 +91,7 @@ const Notifications: React.FC = observer(() => {
|
||||
icon={<MdNotifications />}
|
||||
_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(() => {
|
||||
<MenuList minWidth='300px'>
|
||||
<MenuGroup title={count && count > 0 ? `${count} new notifications` : 'Notifications'}>
|
||||
<MenuDivider />
|
||||
|
||||
{/* MESSAGES */}
|
||||
{messages.length !== 0 && (
|
||||
<MenuItem onClick={() => history.push('/messages')} py='3' icon={<MdOutlineMessage />}>
|
||||
|
||||
@ -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<RootStore | undefined>(undefined);
|
||||
|
||||
export const SocketContext = createContext<{ socket: Socket<ServerToClientEvents, ClientToServerEvents> | null }>({
|
||||
export const SocketContext = createContext<{ socket: Socket | null }>({
|
||||
socket: null,
|
||||
});
|
||||
|
||||
|
||||
25
src/hooks/queries/chat/useFetchConversations.ts
Normal file
25
src/hooks/queries/chat/useFetchConversations.ts
Normal 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 };
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -143,9 +143,6 @@ export interface IMe extends IUser {
|
||||
courses?: UserCourseType[];
|
||||
likes: Array<Pick<ICourse, 'id'>>;
|
||||
notifications: Array<NotificationType>;
|
||||
isAdmin: boolean;
|
||||
isTeacher: boolean;
|
||||
isStudent: boolean;
|
||||
}
|
||||
|
||||
export interface IUserContacts {
|
||||
|
||||
24
src/services/ChatService.ts
Normal file
24
src/services/ChatService.ts
Normal 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);
|
||||
@ -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() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
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;
|
||||
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,
|
||||
...data,
|
||||
isAdmin: helpers.userContainRoles(data.roles, [UserRoleEnum.ADMIN]),
|
||||
isTeacher: helpers.userContainRoles(data.roles, [UserRoleEnum.TEACHER]),
|
||||
isStudent: helpers.userContainRoles(data.roles, [UserRoleEnum.STUDENT]),
|
||||
};
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
src/types/index.d.ts
vendored
7
src/types/index.d.ts
vendored
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user