From 4efe54ad069e8b0a5665e06ec2eaafa6b00aa3d0 Mon Sep 17 00:00:00 2001 From: YaroslavLys <74725159+YaroslavLys@users.noreply.github.com> Date: Fri, 18 Oct 2024 16:12:56 +0300 Subject: [PATCH] Implemented typing animation on the chat page (#2618) * Implemented typing animation on the chat page * Increased test coverage of socketSlice * Fixed sonar issue --- package-lock.json | 18 +++++++ package.json | 3 +- src/assets/lottiefiles/typingAnimation.json | 1 + .../typing-block/TypingBlock.styles.ts | 18 +++++++ src/components/typing-block/TypingBlock.tsx | 39 ++++++++++++++ src/constants/translations/en/chat.json | 3 +- src/constants/translations/uk/chat.json | 3 +- .../chat/chat-item/ChatItem.styles.ts | 8 +++ src/containers/chat/chat-item/ChatItem.tsx | 19 +++++-- src/pages/chat/Chat.tsx | 25 ++++++++- src/pages/chat/MessagesList.tsx | 18 +++++-- src/redux/features/socketSlice.ts | 35 +++++++++++-- src/redux/middleware/socket-middleware.ts | 37 +++++++++++++- src/redux/selectors/socket-selectors.ts | 6 +++ src/styles/app-theme/custom-animations.ts | 11 ++++ .../typing-block/TypingBlock.spec.jsx | 51 +++++++++++++++++++ .../chat/messages-list/MessagesList.spec.jsx | 4 ++ tests/unit/redux/socketMiddleware.spec.js | 1 + tests/unit/redux/socketSlice.spec.js | 49 +++++++++++++++++- 19 files changed, 331 insertions(+), 18 deletions(-) create mode 100644 src/assets/lottiefiles/typingAnimation.json create mode 100644 src/components/typing-block/TypingBlock.styles.ts create mode 100644 src/components/typing-block/TypingBlock.tsx create mode 100644 tests/unit/components/typing-block/TypingBlock.spec.jsx diff --git a/package-lock.json b/package-lock.json index 44323b9b5..257e7dd06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "dompurify": "^3.1.1", "emoji-mart": "^5.5.2", "i18next": "^23.12.2", + "lottie-react": "^2.4.0", "normalize.css": "^8.0.1", "nuka-carousel": "^8.0.1", "react": "^18.2.0", @@ -23330,6 +23331,23 @@ "loose-envify": "cli.js" } }, + "node_modules/lottie-react": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.0.tgz", + "integrity": "sha512-pDJGj+AQlnlyHvOHFK7vLdsDcvbuqvwPZdMlJ360wrzGFurXeKPr8SiRCjLf3LrNYKANQtSsh5dz9UYQHuqx4w==", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/lottie-web": { + "version": "5.12.2", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.12.2.tgz", + "integrity": "sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg==" + }, "node_modules/loud-rejection": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", diff --git a/package.json b/package.json index 29853df26..805b2c9b2 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.16", "@mui/material": "^5.14.16", - "@reduxjs/toolkit": "^2.2.7", "@mui/x-date-pickers": "^5.0.20", + "@reduxjs/toolkit": "^2.2.7", "@tinymce/tinymce-react": "^5.1.1", "allotment": "^1.19.3", "axios": "^1.6.0", @@ -21,6 +21,7 @@ "dompurify": "^3.1.1", "emoji-mart": "^5.5.2", "i18next": "^23.12.2", + "lottie-react": "^2.4.0", "normalize.css": "^8.0.1", "nuka-carousel": "^8.0.1", "react": "^18.2.0", diff --git a/src/assets/lottiefiles/typingAnimation.json b/src/assets/lottiefiles/typingAnimation.json new file mode 100644 index 000000000..ed5a9331c --- /dev/null +++ b/src/assets/lottiefiles/typingAnimation.json @@ -0,0 +1 @@ +{"nm":"Typing","ddd":0,"h":500,"w":500,"meta":{"g":"@lottiefiles/toolkit-js 0.33.2"},"layers":[{"ty":4,"nm":"Rond 3","sr":1,"st":0,"op":1800,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[319.75,271.424,0],"t":36,"ti":[0,0,0],"to":[0,-6.862,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[319.75,230.25,0],"t":61,"ti":[0,-6.862,0],"to":[0,0,0]},{"s":[319.75,271.424,0],"t":92}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Ellipse 1","ix":1,"cix":2,"np":3,"it":[{"ty":"el","bm":0,"hd":false,"mn":"ADBE Vector Shape - Ellipse","nm":"Tracé d'ellipse 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"s":{"a":0,"k":[47,47],"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Contour 1","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"c":{"a":0,"k":[0.0039,0.6196,0.5255],"ix":3}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[0.5686,0.6,0.6314],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[9.75,1.25],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1},{"ty":4,"nm":"Rond 2","sr":1,"st":0,"op":1800,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[241.125,271.424,0],"t":18,"ti":[0,0,0],"to":[0,-6.862,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[241.125,230.25,0],"t":43,"ti":[0,-3.157,0],"to":[0,0,0]},{"s":[241.125,271.424,0],"t":74}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Ellipse 1","ix":1,"cix":2,"np":3,"it":[{"ty":"el","bm":0,"hd":false,"mn":"ADBE Vector Shape - Ellipse","nm":"Tracé d'ellipse 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"s":{"a":0,"k":[47,47],"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Contour 1","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"c":{"a":0,"k":[0.0039,0.6196,0.5255],"ix":3}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[0.5686,0.6,0.6314],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[9.75,1.25],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2},{"ty":4,"nm":"Rond 1","sr":1,"st":0,"op":1800,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[162.5,271.424,0],"t":0,"ti":[0,0,0],"to":[0,-6.862,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[162.5,230.25,0],"t":25,"ti":[0,-6.862,0],"to":[0,0,0]},{"s":[162.5,271.424,0],"t":56}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Ellipse 1","ix":1,"cix":2,"np":3,"it":[{"ty":"el","bm":0,"hd":false,"mn":"ADBE Vector Shape - Ellipse","nm":"Tracé d'ellipse 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"s":{"a":0,"k":[47,47],"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Contour 1","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"c":{"a":0,"k":[0.0039,0.6196,0.5255],"ix":3}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[0.5686,0.6,0.6314],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[9.75,1.25],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":3},{"ty":4,"nm":"Fond","sr":1,"st":0,"op":1800,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[125.373,134.884,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[247.096,185,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Rectangle 1","ix":1,"cix":2,"np":4,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Tracé 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-22.091],[0,0],[22.091,0],[0,0],[0,22.091],[0,0],[-22.091,0],[0,0]],"o":[[0,0],[0,22.091],[0,0],[-22.091,0],[0,0],[0.25,-43.75],[0,0],[22.091,0]],"v":[[131,-24],[131,24],[91,64],[-91,64],[-131,24],[-131,-24],[-113,-63.75],[91,-64]]},"ix":2}},{"ty":"rc","bm":0,"hd":false,"mn":"ADBE Vector Shape - Rect","nm":"Tracé rectangulaire 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":80,"ix":4},"s":{"a":0,"k":[262,128],"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Contour 1","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"c":{"a":0,"k":[0.0039,0.6196,0.5255],"ix":3}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[0.8118,0.8471,0.8627],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[3,65],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":4}],"v":"5.7.4","fr":60,"op":120,"ip":0,"assets":[]} \ No newline at end of file diff --git a/src/components/typing-block/TypingBlock.styles.ts b/src/components/typing-block/TypingBlock.styles.ts new file mode 100644 index 000000000..eac62d7ac --- /dev/null +++ b/src/components/typing-block/TypingBlock.styles.ts @@ -0,0 +1,18 @@ +export const styles = { + wrapper: { + display: 'flex', + flexDirection: 'row', + overflow: 'hidden' + }, + avatar: { + width: '44px', + height: '44px', + '&:hover': { transform: 'scale(1.1)' } + }, + typingAnimation: { + width: '178px', + height: '116px', + marginTop: '-40px', + marginLeft: '-42px' + } +} diff --git a/src/components/typing-block/TypingBlock.tsx b/src/components/typing-block/TypingBlock.tsx new file mode 100644 index 000000000..6e09ea203 --- /dev/null +++ b/src/components/typing-block/TypingBlock.tsx @@ -0,0 +1,39 @@ +import Box from '@mui/material/Box' +import Lottie from 'lottie-react' + +import { styles } from '~/components/typing-block/TypingBlock.styles' + +import AvatarIcon from '~/components/avatar-icon/AvatarIcon' +import { createUrlPath } from '~/utils/helper-functions' +import { Member } from '~/types' +import typingAnimation from '~/assets/lottiefiles/typingAnimation.json' + +interface TypingBlockProps { + userToSpeak: Member +} + +const TypingBlock = ({ userToSpeak }: TypingBlockProps) => { + return ( + + + + + ) +} + +export default TypingBlock diff --git a/src/constants/translations/en/chat.json b/src/constants/translations/en/chat.json index 624e0c81b..782a44900 100644 --- a/src/constants/translations/en/chat.json +++ b/src/constants/translations/en/chat.json @@ -1,7 +1,8 @@ { "message": { "you": "You", - "noMessages": "No messages yet" + "noMessages": "No messages yet", + "isTyping": "is typing..." }, "status": { "online": "Online", diff --git a/src/constants/translations/uk/chat.json b/src/constants/translations/uk/chat.json index 09cfc22d9..e62a97790 100644 --- a/src/constants/translations/uk/chat.json +++ b/src/constants/translations/uk/chat.json @@ -1,7 +1,8 @@ { "message": { "you": "Ви", - "noMessages": "Повідомлень немає" + "noMessages": "Повідомлень немає", + "isTyping": "пише..." }, "status": { "online": "В мережі", diff --git a/src/containers/chat/chat-item/ChatItem.styles.ts b/src/containers/chat/chat-item/ChatItem.styles.ts index 1e96d9741..56765e52d 100644 --- a/src/containers/chat/chat-item/ChatItem.styles.ts +++ b/src/containers/chat/chat-item/ChatItem.styles.ts @@ -1,4 +1,5 @@ import { TypographyVariantEnum } from '~/types' +import { colorChangeAnimation } from '~/styles/app-theme/custom-animations' const hideText = { textOverflow: 'ellipsis', @@ -68,6 +69,13 @@ export const styles = { typography: TypographyVariantEnum.Body1, ...hideText }, + isTypingMessage: { + width: '100%', + marginRight: '28px', + typography: TypographyVariantEnum.Body1, + ...hideText, + ...colorChangeAnimation + }, amountOfmessages: { borderRadius: '50%', width: '24px', diff --git a/src/containers/chat/chat-item/ChatItem.tsx b/src/containers/chat/chat-item/ChatItem.tsx index 4578845d7..397c1badd 100644 --- a/src/containers/chat/chat-item/ChatItem.tsx +++ b/src/containers/chat/chat-item/ChatItem.tsx @@ -5,7 +5,10 @@ import Typography from '@mui/material/Typography' import Badge from '@mui/material/Badge' import AvatarIcon from '~/components/avatar-icon/AvatarIcon' -import { selectIsUserOnline } from '~/redux/selectors/socket-selectors' +import { + selectIsTyping, + selectIsUserOnline +} from '~/redux/selectors/socket-selectors' import { useAppSelector } from '~/hooks/use-redux' @@ -39,6 +42,7 @@ const ChatItem: FC = ({ [chat, userId] ) as Member const isOnline = useAppSelector(selectIsUserOnline(userToSpeak.user._id)) + const isTyping = useAppSelector(selectIsTyping(chat._id)) const firstName = userToSpeak?.user.firstName const lastName = userToSpeak?.user.lastName @@ -114,9 +118,16 @@ const ChatItem: FC = ({ {formattedTime} - {isCurrentUser} - {text} - + {isTyping ? ( + + {t('chatPage.message.isTyping')} + + ) : ( + <> + {isCurrentUser} + {text} + + )} {3} diff --git a/src/pages/chat/Chat.tsx b/src/pages/chat/Chat.tsx index 126b513a2..f99ce1f1b 100644 --- a/src/pages/chat/Chat.tsx +++ b/src/pages/chat/Chat.tsx @@ -9,8 +9,8 @@ import { messageService } from '~/services/message-service' import { useDrawer } from '~/hooks/use-drawer' import useAxios from '~/hooks/use-axios' import useBreakpoints from '~/hooks/use-breakpoints' +import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' -import { useAppSelector } from '~/hooks/use-redux' import { useChatContext } from '~/context/chat-context' import PageWrapper from '~/components/page-wrapper/PageWrapper' import AppDrawer from '~/components/app-drawer/AppDrawer' @@ -33,6 +33,8 @@ import { PositionEnum } from '~/types' import MessagesList from './MessagesList' +import { joinChat, leaveChat, sendTyping } from '~/redux/features/socketSlice' +import { useDebounce } from '~/hooks/use-debounce' const Chat = () => { const { t } = useTranslation() @@ -50,6 +52,15 @@ const Chat = () => { const [prevScrollTop, setPrevScrollTop] = useState(0) const { setChatInfo, chatInfo } = useChatContext() const { userId: myId } = useAppSelector((state) => state.appMain) + const dispatch = useAppDispatch() + + useEffect(() => { + dispatch(joinChat()) + + return () => { + dispatch(leaveChat()) + } + }, [dispatch]) const limit = 15 @@ -58,6 +69,15 @@ const Chat = () => { [selectedChat, myId] ) + const handleKeyDown = useDebounce(() => { + dispatch( + sendTyping({ + chatId: selectedChat!._id, + receiverId: userToSpeak!.user._id + }) + ) + }, 100) + const markedAsDeleted = useMemo( () => selectedChat && selectedChat?.deletedFor?.length > 0, [selectedChat] @@ -218,6 +238,7 @@ const Chat = () => { void onMessageSend()} + onKeyDown={handleKeyDown} setValue={setTextAreaValue} value={textAreaValue} /> @@ -286,6 +307,7 @@ const Chat = () => { /> )} { messages={messages} scrollHeight={!skip ? 0 : prevScrollHeight} scrollTop={!skip ? 0 : prevScrollTop} + userToSpeak={userToSpeak as Member} /> {renderChatTextArea()} diff --git a/src/pages/chat/MessagesList.tsx b/src/pages/chat/MessagesList.tsx index f533243b9..ef8e56134 100644 --- a/src/pages/chat/MessagesList.tsx +++ b/src/pages/chat/MessagesList.tsx @@ -3,12 +3,16 @@ import Box from '@mui/material/Box' import SimpleBar from 'simplebar-react' import { useTranslation } from 'react-i18next' -import { MessageInterface } from '~/types' -import { getGroupedByDate, getIsNewDay } from '~/utils/helper-functions' +import { styles } from '~/pages/chat/Chat.styles' + import ChatDate from '~/containers/chat/chat-date/ChatDate' import Message from '~/components/message/Message' -import { styles } from '~/pages/chat/Chat.styles' +import TypingBlock from '~/components/typing-block/TypingBlock' import AppChip from '~/components/app-chip/AppChip' +import { useAppSelector } from '~/hooks/use-redux' +import { selectIsTyping } from '~/redux/selectors/socket-selectors' +import { Member, MessageInterface } from '~/types' +import { getGroupedByDate, getIsNewDay } from '~/utils/helper-functions' interface MessagesListProps { messages: MessageInterface[] @@ -18,6 +22,8 @@ interface MessagesListProps { scrollTop: number scrollHeight: number infiniteLoadCallback: (scrollTop: number, scrollHeight: number) => void + chatId: string + userToSpeak: Member } const MessagesList = ({ @@ -27,11 +33,14 @@ const MessagesList = ({ isMessagesLoading, infiniteLoadCallback, scrollTop, - scrollHeight + scrollHeight, + chatId, + userToSpeak }: MessagesListProps) => { const { t } = useTranslation() const observer = useRef() const scrollRef = useRef() + const isTyping = useAppSelector(selectIsTyping(chatId)) useLayoutEffect(() => { if (!scrollRef.current) return @@ -110,6 +119,7 @@ const MessagesList = ({ style={styles.scrollableContent} > {messagesListWithDate} + {isTyping && } ) } diff --git a/src/redux/features/socketSlice.ts b/src/redux/features/socketSlice.ts index d5baf81c7..c0c6c3c92 100644 --- a/src/redux/features/socketSlice.ts +++ b/src/redux/features/socketSlice.ts @@ -4,11 +4,13 @@ import { sliceNames } from '~/redux/redux.constants' interface SocketState { isConnected: boolean usersOnline: string[] + isTypingChats: string[] } const initialState: SocketState = { isConnected: false, - usersOnline: [] + usersOnline: [], + isTypingChats: [] } const socketSlice = createSlice({ @@ -23,12 +25,39 @@ const socketSlice = createSlice({ }, setUsersOnline: (state, action: PayloadAction) => { state.usersOnline = action.payload - } + }, + addIsTyping: (state, action: PayloadAction) => { + if (!state.isTypingChats.includes(action.payload)) { + state.isTypingChats.push(action.payload) + } + }, + removeIsTyping: (state, action: PayloadAction) => { + state.isTypingChats = state.isTypingChats.filter( + (chatId) => chatId !== action.payload + ) + }, + joinChat: () => {}, + leaveChat: () => {}, + sendTyping: ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + state, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + action: PayloadAction<{ chatId: string; receiverId: string }> + ) => {} } }) const { actions, reducer } = socketSlice -export const { connectSocket, disconnectSocket, setUsersOnline } = actions +export const { + connectSocket, + disconnectSocket, + setUsersOnline, + addIsTyping, + removeIsTyping, + joinChat, + leaveChat, + sendTyping +} = actions export default reducer diff --git a/src/redux/middleware/socket-middleware.ts b/src/redux/middleware/socket-middleware.ts index 25242001e..156ffe3d8 100644 --- a/src/redux/middleware/socket-middleware.ts +++ b/src/redux/middleware/socket-middleware.ts @@ -2,8 +2,13 @@ import { Middleware } from 'redux' import { Action } from '@reduxjs/toolkit' import SocketFactory, { SocketInterface } from '~/redux/socket-factory' import { + addIsTyping, connectSocket, disconnectSocket, + joinChat, + leaveChat, + removeIsTyping, + sendTyping, setUsersOnline } from '~/redux/features/socketSlice' import { logout, setUser } from '~/redux/reducer' @@ -12,16 +17,34 @@ import { debounce } from '~/utils/debounce' enum SocketEvent { Connect = 'connect', ConnectUser = 'connectUser', - UsersOnline = 'usersOnline' + UsersOnline = 'usersOnline', + TypingStatus = 'typingStatus', + SendTypingStatus = 'sendTypingStatus' } const socketMiddleware: Middleware = (store) => { let socket: SocketInterface + const typingTimers: Map = new Map() const debouncedSetUsersOnline = debounce((users: string[]) => { store.dispatch(setUsersOnline(users)) }, 1000) + const typingStatusListener = (chatId: string) => { + store.dispatch(addIsTyping(chatId)) + + if (typingTimers.has(chatId)) { + clearTimeout(typingTimers.get(chatId)) + } + + const timeoutId = setTimeout(() => { + store.dispatch(removeIsTyping(chatId)) + typingTimers.delete(chatId) + }, 1000) + + typingTimers.set(chatId, timeoutId) + } + return (next) => (action: Action) => { if (setUser.match(action)) { if (socket) { @@ -47,6 +70,18 @@ const socketMiddleware: Middleware = (store) => { } } + if (joinChat.match(action)) { + socket.socket.on(SocketEvent.TypingStatus, typingStatusListener) + } + + if (leaveChat.match(action)) { + socket.socket.off(SocketEvent.TypingStatus, typingStatusListener) + } + + if (sendTyping.match(action)) { + socket.socket.emit(SocketEvent.SendTypingStatus, action.payload) + } + next(action) } } diff --git a/src/redux/selectors/socket-selectors.ts b/src/redux/selectors/socket-selectors.ts index c1889022c..357fd03c5 100644 --- a/src/redux/selectors/socket-selectors.ts +++ b/src/redux/selectors/socket-selectors.ts @@ -6,3 +6,9 @@ export const selectIsUserOnline = (userId: string) => (state: RootState) => state.socket.usersOnline, (usersOnline: string[]) => usersOnline.includes(userId) ) + +export const selectIsTyping = (chatId: string) => + createSelector( + (state: RootState) => state.socket.isTypingChats, + (isTypingChats: string[]) => isTypingChats.includes(chatId) + ) diff --git a/src/styles/app-theme/custom-animations.ts b/src/styles/app-theme/custom-animations.ts index 9e449145f..a8f655f7d 100644 --- a/src/styles/app-theme/custom-animations.ts +++ b/src/styles/app-theme/custom-animations.ts @@ -1,4 +1,5 @@ import { keyframes } from '@mui/system' +import palette from '~/styles/app-theme/app.pallete' export const fade = keyframes` from { @@ -39,6 +40,12 @@ export const SlidesLeftLong = keyframes` } ` +export const colorChange = keyframes` + 0% { color: ${palette.success[300]}; } + 50% { color: ${palette.success[500]}; } + 100% { color: ${palette.success[300]}; } +` + export const fadeAnimation = { animation: `${fade} 0.5s ease-in` } @@ -54,3 +61,7 @@ export const slidesLeftAnimation = { export const SlideLeftLongAnimation = { animation: `${SlidesLeftLong} .5s ease-in-out` } + +export const colorChangeAnimation = { + animation: `${colorChange} 1.5s ease-in-out infinite` +} diff --git a/tests/unit/components/typing-block/TypingBlock.spec.jsx b/tests/unit/components/typing-block/TypingBlock.spec.jsx new file mode 100644 index 000000000..2c56caae7 --- /dev/null +++ b/tests/unit/components/typing-block/TypingBlock.spec.jsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import TypingBlock from '~/components/typing-block/TypingBlock' +import AvatarIcon from '~/components/avatar-icon/AvatarIcon' + +vi.mock('lottie-react', () => ({ + default: vi.fn(() =>
) +})) + +vi.mock('~/components/avatar-icon/AvatarIcon', () => ({ + default: vi.fn(() =>
) +})) + +describe('TypingBlock', () => { + const userToSpeak = { + user: { + firstName: 'John', + lastName: 'Doe', + photo: 'photo.jpg' + } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders AvatarIcon with correct props', () => { + render() + + const avatarIcon = screen.getByTestId('avatar-icon') + + expect(avatarIcon).toBeInTheDocument() + expect(AvatarIcon).toHaveBeenCalledWith( + { + firstName: 'John', + lastName: 'Doe', + photo: expect.any(String), + sx: expect.any(Object) + }, + {} + ) + }) + + it('renders Lottie animation', () => { + render() + + const lottieAnimation = screen.getByTestId('lottie-animation') + + expect(lottieAnimation).toBeInTheDocument() + }) +}) diff --git a/tests/unit/pages/chat/messages-list/MessagesList.spec.jsx b/tests/unit/pages/chat/messages-list/MessagesList.spec.jsx index 96333ecc6..33586602e 100644 --- a/tests/unit/pages/chat/messages-list/MessagesList.spec.jsx +++ b/tests/unit/pages/chat/messages-list/MessagesList.spec.jsx @@ -6,6 +6,10 @@ vi.mock('~/components/message/Message', () => ({ default: vi.fn(() =>
Mock Message
) })) +vi.mock('~/components/typing-block/TypingBlock', () => ({ + default: vi.fn(() =>
Typing animation
) +})) + vi.spyOn(window, "getComputedStyle").mockReturnValue(new CSSStyleDeclaration) global.IntersectionObserver = vi.fn().mockImplementation((callback) => ({ diff --git a/tests/unit/redux/socketMiddleware.spec.js b/tests/unit/redux/socketMiddleware.spec.js index 5b2d80bdd..e79e26cc3 100644 --- a/tests/unit/redux/socketMiddleware.spec.js +++ b/tests/unit/redux/socketMiddleware.spec.js @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import socketMiddleware from '~/redux/middleware/socket-middleware' import { connectSocket, setUsersOnline } from '~/redux/features/socketSlice' import SocketFactory from '~/redux/socket-factory' diff --git a/tests/unit/redux/socketSlice.spec.js b/tests/unit/redux/socketSlice.spec.js index 20de21b0f..64771156f 100644 --- a/tests/unit/redux/socketSlice.spec.js +++ b/tests/unit/redux/socketSlice.spec.js @@ -1,12 +1,16 @@ import reducer, { connectSocket, disconnectSocket, - setUsersOnline + setUsersOnline, + addIsTyping, + removeIsTyping, + sendTyping } from '~/redux/features/socketSlice' const initialState = { isConnected: false, - usersOnline: [] + usersOnline: [], + isTypingChats: [] } const createState = (overrides) => ({ @@ -48,4 +52,45 @@ describe('socketSlice test', () => { expectedState ) }) + + it('should add new chatId to isTypingChats', () => { + const expectedState = createState({ + isTypingChats: ['chat1'] + }) + + expect(reducer(undefined, addIsTyping('chat1'))).toEqual(expectedState) + }) + + it('should not add new chatId to isTypingChats', () => { + const previousState = createState({ + isTypingChats: ['chat1'] + }) + + expect(reducer(previousState, addIsTyping('chat1'))).toEqual(previousState) + }) + + it('should remove chatId from isTypingChats', () => { + const previousState = createState({ + isTypingChats: ['chat1'] + }) + const expectedState = createState({ + isTypingChats: [] + }) + + expect(reducer(previousState, removeIsTyping('chat1'))).toEqual( + expectedState + ) + }) + + it('should send correct payload in sendTyping', () => { + expect( + reducer( + initialState, + sendTyping({ + chatId: 'chat1', + receiverId: 'receiver1' + }) + ) + ).toEqual(initialState) + }) })