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)
+ })
})