Skip to content

Commit

Permalink
Implemented typing animation on the chat page (#2618)
Browse files Browse the repository at this point in the history
* Implemented typing animation on the chat page

* Increased test coverage of socketSlice

* Fixed sonar issue
  • Loading branch information
YaroslavLys authored Oct 18, 2024
1 parent 8f0f4e4 commit 4efe54a
Show file tree
Hide file tree
Showing 19 changed files with 331 additions and 18 deletions.
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
"@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",
"date-fns": "^2.30.0",
"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",
Expand Down
1 change: 1 addition & 0 deletions src/assets/lottiefiles/typingAnimation.json
Original file line number Diff line number Diff line change
@@ -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":[]}
18 changes: 18 additions & 0 deletions src/components/typing-block/TypingBlock.styles.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
39 changes: 39 additions & 0 deletions src/components/typing-block/TypingBlock.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box sx={styles.wrapper}>
<AvatarIcon
firstName={userToSpeak.user.firstName}
lastName={userToSpeak.user.lastName}
photo={
userToSpeak.user.photo &&
createUrlPath(
import.meta.env.VITE_APP_IMG_USER_URL,
userToSpeak.user.photo
)
}
sx={styles.avatar}
/>
<Lottie
animationData={typingAnimation}
loop
style={styles.typingAnimation}
/>
</Box>
)
}

export default TypingBlock
3 changes: 2 additions & 1 deletion src/constants/translations/en/chat.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"message": {
"you": "You",
"noMessages": "No messages yet"
"noMessages": "No messages yet",
"isTyping": "is typing..."
},
"status": {
"online": "Online",
Expand Down
3 changes: 2 additions & 1 deletion src/constants/translations/uk/chat.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"message": {
"you": "Ви",
"noMessages": "Повідомлень немає"
"noMessages": "Повідомлень немає",
"isTyping": "пише..."
},
"status": {
"online": "В мережі",
Expand Down
8 changes: 8 additions & 0 deletions src/containers/chat/chat-item/ChatItem.styles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TypographyVariantEnum } from '~/types'
import { colorChangeAnimation } from '~/styles/app-theme/custom-animations'

const hideText = {
textOverflow: 'ellipsis',
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 15 additions & 4 deletions src/containers/chat/chat-item/ChatItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -39,6 +42,7 @@ const ChatItem: FC<ItemOfChatProps> = ({
[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
Expand Down Expand Up @@ -114,9 +118,16 @@ const ChatItem: FC<ItemOfChatProps> = ({
<Typography sx={styles.lastTimeMessage}>{formattedTime}</Typography>
</Box>
<Box sx={styles.messageBlock}>
{isCurrentUser}
<Typography sx={styles.message}>{text}</Typography>

{isTyping ? (
<Typography sx={styles.isTypingMessage}>
{t('chatPage.message.isTyping')}
</Typography>
) : (
<>
{isCurrentUser}
<Typography sx={styles.message}>{text}</Typography>
</>
)}
<Box>
<Typography sx={styles.amountOfmessages}>{3}</Typography>
</Box>
Expand Down
25 changes: 24 additions & 1 deletion src/pages/chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
Expand All @@ -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

Expand All @@ -58,6 +69,15 @@ const Chat = () => {
[selectedChat, myId]
)

const handleKeyDown = useDebounce(() => {
dispatch(
sendTyping({
chatId: selectedChat!._id,
receiverId: userToSpeak!.user._id
})
)
}, 100)

const markedAsDeleted = useMemo<boolean | null>(
() => selectedChat && selectedChat?.deletedFor?.length > 0,
[selectedChat]
Expand Down Expand Up @@ -218,6 +238,7 @@ const Chat = () => {
<ChatTextArea
label={t('chatPage.chat.inputLabel')}
onClick={() => void onMessageSend()}
onKeyDown={handleKeyDown}
setValue={setTextAreaValue}
value={textAreaValue}
/>
Expand Down Expand Up @@ -286,13 +307,15 @@ const Chat = () => {
/>
)}
<MessagesList
chatId={selectedChat._id}
filteredIndex={filteredIndex}
filteredMessages={filteredMessages}
infiniteLoadCallback={handleInifiniteLoad}
isMessagesLoading={isMessagesLoading}
messages={messages}
scrollHeight={!skip ? 0 : prevScrollHeight}
scrollTop={!skip ? 0 : prevScrollTop}
userToSpeak={userToSpeak as Member}
/>
{renderChatTextArea()}
</>
Expand Down
18 changes: 14 additions & 4 deletions src/pages/chat/MessagesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -18,6 +22,8 @@ interface MessagesListProps {
scrollTop: number
scrollHeight: number
infiniteLoadCallback: (scrollTop: number, scrollHeight: number) => void
chatId: string
userToSpeak: Member
}

const MessagesList = ({
Expand All @@ -27,11 +33,14 @@ const MessagesList = ({
isMessagesLoading,
infiniteLoadCallback,
scrollTop,
scrollHeight
scrollHeight,
chatId,
userToSpeak
}: MessagesListProps) => {
const { t } = useTranslation()
const observer = useRef<IntersectionObserver>()
const scrollRef = useRef<HTMLDivElement>()
const isTyping = useAppSelector(selectIsTyping(chatId))

useLayoutEffect(() => {
if (!scrollRef.current) return
Expand Down Expand Up @@ -110,6 +119,7 @@ const MessagesList = ({
style={styles.scrollableContent}
>
{messagesListWithDate}
{isTyping && <TypingBlock userToSpeak={userToSpeak} />}
</SimpleBar>
)
}
Expand Down
35 changes: 32 additions & 3 deletions src/redux/features/socketSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -23,12 +25,39 @@ const socketSlice = createSlice({
},
setUsersOnline: (state, action: PayloadAction<string[]>) => {
state.usersOnline = action.payload
}
},
addIsTyping: (state, action: PayloadAction<string>) => {
if (!state.isTypingChats.includes(action.payload)) {
state.isTypingChats.push(action.payload)
}
},
removeIsTyping: (state, action: PayloadAction<string>) => {
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
Loading

0 comments on commit 4efe54a

Please sign in to comment.