Skip to content

Commit

Permalink
Merge pull request #137 from prgrms-web-devcourse-final-project/40-fe…
Browse files Browse the repository at this point in the history
…ature/chatting

[Feature] #40 채팅 구현
  • Loading branch information
shlee9999 authored Dec 7, 2024
2 parents 64a73e2 + 5a63f55 commit dc7bb76
Show file tree
Hide file tree
Showing 30 changed files with 486 additions and 383 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ module.exports = {
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
},
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 10",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 30",
"preview": "vite preview",
"pwa": "pwa-assets-generator --preset minimal public/test.svg",
"format": "prettier --check --ignore-path .gitignore \"**/*.{ts,tsx}\"",
Expand Down
37 changes: 17 additions & 20 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { router } from '~/router'
import GlobalStyle from '~/styles/globalStyle'
import { darkTheme, lightTheme } from '~/styles/theme'
import Loader from '~components/Loader'
import { WebSocketProvider } from '~/WebSocketContext'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

Expand All @@ -18,29 +17,27 @@ function App() {
const toggleTheme = () => setTheme(prev => (prev === lightTheme ? darkTheme : lightTheme))
return (
<>
<WebSocketProvider>
<QueryClientProvider client={queryClient}>
<HelmetProvider>
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<Helmet>
<title>DDang</title>
<meta name='description' content='반려견과 함께하는 즐거운 산책, DDang.' />
</Helmet>
<button onClick={toggleTheme} hidden>
Toggle Theme
</button>
<GlobalStyle />
<MobileContainer>
<Suspense fallback={<Loader />}>
<RouterProvider router={router} />
</Suspense>
</MobileContainer>
<PWABadge />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
<Helmet>
<title>DDang</title>
<meta name='description' content='반려견과 함께하는 즐거운 산책, DDang.' />
</Helmet>
<button onClick={toggleTheme} hidden>
Toggle Theme
</button>
<GlobalStyle />
<MobileContainer>
<Suspense fallback={<Loader />}>
<RouterProvider router={router} />
</Suspense>
</MobileContainer>
<PWABadge />
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
</HelmetProvider>
</WebSocketProvider>
</QueryClientProvider>
</>
)
}
Expand Down
8 changes: 6 additions & 2 deletions src/WebSocketContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { createContext, useContext, useEffect, useState } from 'react'
import { Client } from '@stomp/stompjs'
import React, { createContext, useContext, useEffect, useState } from 'react'
import SockJS from 'sockjs-client'

interface WebSocketContextType {
Expand All @@ -11,12 +11,13 @@ interface WebSocketContextType {

const WebSocketContext = createContext<WebSocketContextType | null>(null)

const token = localStorage.getItem('token')

export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
const [client, setClient] = useState<Client | null>(null)
const [isConnected, setIsConnected] = useState(false)

useEffect(() => {
const token = localStorage.getItem('token')
const SERVER_URL = 'https://ddang.shop/ws'

const socket = new SockJS(SERVER_URL)
Expand Down Expand Up @@ -62,6 +63,9 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) =
client.publish({
destination,
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${token}`,
},
})
}
}
Expand Down
46 changes: 46 additions & 0 deletions src/apis/chat/fetchChatMessageList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { AxiosError } from 'axios'
import { APIResponse, CommonAPIRequest, CommonAPIResponse, ErrorResponse, PaginationResponse } from '~types/api'
import { axiosInstance } from '~apis/axiosInstance'

export type FetchChatMessageListRequest = Pick<CommonAPIRequest, 'chatRoomId' | 'lastMessageCreatedAt'>

export type FetchChatMessageListResponse = PaginationResponse & {
content: Array<
Pick<CommonAPIResponse, 'chatId' | 'createdAt' | 'updatedAt' | 'chatRoomId' | 'memberInfo' | 'isRead' | 'text'>
>
}

export const fetchChatMessageList = async ({
chatRoomId,
lastMessageCreatedAt,
}: FetchChatMessageListRequest): Promise<APIResponse<FetchChatMessageListResponse>> => {
try {
const { data } = await axiosInstance.get<APIResponse<FetchChatMessageListResponse>>(`/chat/message/${chatRoomId}`, {
params: { lastMessageCreatedAt },
})
return data
} catch (error) {
if (error instanceof AxiosError) {
const { response } = error as AxiosError<ErrorResponse>

if (response) {
const { code, message } = response.data
switch (code) {
case 400:
throw new Error(message || '잘못된 요청입니다.')
case 401:
throw new Error(message || '인증에 실패했습니다.')
case 500:
throw new Error(message || '서버 오류가 발생했습니다.')
default:
throw new Error(message || '알 수 없는 오류가 발생했습니다.')
}
}
// 요청 자체가 실패한 경우
throw new Error('네트워크 연결을 확인해주세요')
}

console.error('예상치 못한 에러:', error)
throw new Error('다시 시도해주세요')
}
}
20 changes: 20 additions & 0 deletions src/apis/chat/useChatMessageList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
import { fetchChatMessageList } from '~apis/chat/fetchChatMessageList'
import { queryKey } from '~constants/queryKey'

type useChatMessageListProps = {
chatRoomId: number
}

export default function useChatMessageList({ chatRoomId }: useChatMessageListProps) {
return useSuspenseInfiniteQuery({
queryKey: queryKey.social.chatMessageList(chatRoomId),
queryFn: async ({ pageParam }) => {
return await fetchChatMessageList({ lastMessageCreatedAt: pageParam as string, chatRoomId })
},
getNextPageParam: lastPage => {
return lastPage.data.last ? undefined : lastPage.data.content[0].createdAt
},
initialPageParam: '',
})
}
10 changes: 10 additions & 0 deletions src/apis/chatRoom/useChatList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { fetchChatRoomList } from '~apis/chatRoom/fetchChatRoomList'
import { queryKey } from '~constants/queryKey'

export default function useChatList() {
return useQuery({
queryKey: queryKey.social.chatRoomList(),
queryFn: () => fetchChatRoomList().then(res => res.data),
})
}
7 changes: 6 additions & 1 deletion src/apis/chatRoom/useCreateChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ChatModal from '~modals/ChatModal'
import { useModalStore } from '~stores/modalStore'
import { APIResponse } from '~types/api'
import { createChatRoom, CreateChatRoomRequest, CreateChatRoomResponse } from './createChatRoom'
import { useWebSocket } from '~/WebSocketContext'

export const useCreateChatRoom = (): UseMutationResult<
APIResponse<CreateChatRoomResponse>,
Expand All @@ -12,12 +13,16 @@ export const useCreateChatRoom = (): UseMutationResult<
createRoom: (req: CreateChatRoomRequest) => void
} => {
const { pushModal } = useModalStore()
const { isConnected } = useWebSocket()

const mutation = useMutation<APIResponse<CreateChatRoomResponse>, Error, CreateChatRoomRequest>({
mutationFn: createChatRoom,
onSuccess: (data, { opponentMemberId }) => {
if (!isConnected) {
throw new Error('WebSocket 연결이 되어 있지 않습니다.')
}
console.log(data.message || '채팅방 생성 성공')
pushModal(<ChatModal chatRoomId={data.data.chatRoomId} userId={opponentMemberId} />)
pushModal(<ChatModal chatRoomId={data.data.chatRoomId} opponentMemberId={opponentMemberId} />)
},
onError: error => {
console.error('채팅방 생성 실패:', error.message)
Expand Down
10 changes: 10 additions & 0 deletions src/apis/chatRoom/useFriendList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { fetchFriendList } from '~apis/friend/fetchFriendList'
import { queryKey } from '~constants/queryKey'

export default function useFriendList() {
return useQuery({
queryKey: queryKey.social.friendList(),
queryFn: () => fetchFriendList().then(res => res.data),
})
}
28 changes: 0 additions & 28 deletions src/apis/chatRoom/useSocialData.tsx

This file was deleted.

17 changes: 17 additions & 0 deletions src/apis/friend/requestFriend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { axiosInstance } from '~apis/axiosInstance'
import { APIResponse, CommonAPIResponse } from '~types/api'

export type RequestFriendRequest = {
memberId: number
decision: 'ACCEPT' | 'DENY'
}

export type RequestFriendResponse = Pick<
CommonAPIResponse,
'memberId' | 'name' | 'email' | 'provider' | 'gender' | 'address' | 'familyRole' | 'profileImg'
>

export const requestFriend = async (req: RequestFriendRequest): Promise<APIResponse<RequestFriendResponse>> => {
const { data } = await axiosInstance.post<APIResponse<RequestFriendResponse>>(`/friend`, req)
return data
}
13 changes: 12 additions & 1 deletion src/apis/main/fetchHomePageData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ import { axiosInstance } from '~apis/axiosInstance'

export type FetchHomePageDataResponse = Pick<
CommonAPIResponse,
'memberId' | 'familyRole' | 'dogName' | 'timeDuration' | 'totalDistanceMeter' | 'totalCalorie' | 'memberProfileImgUrl'
| 'memberId'
| 'familyRole'
| 'address'
| 'email'
| 'memberGender'
| 'memberName'
| 'memberProfileImgUrl'
| 'provider'
| 'dogName'
| 'timeDuration'
| 'totalDistanceMeter'
| 'totalCalorie'
>

export const fetchHomePageData = async (): Promise<APIResponse<FetchHomePageDataResponse>> => {
Expand Down
9 changes: 8 additions & 1 deletion src/components/Footer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { FaRegCalendarCheck } from 'react-icons/fa6'
import { IoMdPeople } from 'react-icons/io'
import { IoHomeSharp } from 'react-icons/io5'
import { MdOutlineFamilyRestroom } from 'react-icons/md'
import { useNavigate } from 'react-router-dom'
import { useTheme } from 'styled-components'
import useChatList from '~apis/chatRoom/useChatList'
import { Typo11 } from '~components/Typo'
import * as S from './styles'
import { useNavigate } from 'react-router-dom'

const FOOTER_NAV_LIST = [
{ Icon: IoHomeSharp, endpoint: '/', typo: '홈' },
Expand Down Expand Up @@ -34,6 +35,11 @@ export default function Footer() {
}
navigate(endpoint)
}
const { data } = useChatList()
let sum = 0
data?.forEach(({ unreadMessageCount }) => {
sum += unreadMessageCount
})

return (
<S.Footer>
Expand All @@ -47,6 +53,7 @@ export default function Footer() {
handleNavigation(endpoint)
}}
>
{typo === '소셜' && <S.ChatCount>{sum}</S.ChatCount>}
<Icon color={theme.colors.brand.default} size={28} />
<Typo11 $weight='500' $color='font_3'>
{typo}
Expand Down
13 changes: 13 additions & 0 deletions src/components/Footer/styles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'
import { styled } from 'styled-components'
import { UnreadChatCount } from '~components/UnreadChatCount'
import { FOOTER_HEIGHT } from '~constants/layout'

export const Footer = styled.footer`
Expand All @@ -18,10 +19,22 @@ export const FooterNavList = styled.ul`
`

export const FooterNavItem = styled(Link)`
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-decoration: none;
`

export const ChatCount = styled(UnreadChatCount)`
left: calc(50% + 3px);
top: 2px;
translate: 8px;
font-size: ${({ theme }) => theme.typography._9};
min-width: 0;
width: 14px;
height: 14px;
border-radius: 50%;
`
31 changes: 17 additions & 14 deletions src/components/SendMessageForm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
import { Typo14 } from '~components/Typo'
import * as S from './styles'
import { createChatRoom } from '~apis/chatRoom/createChatRoom'
import { CommonAPIResponse } from '~types/api'
import * as S from './styles'
import { useWebSocket } from '~/WebSocketContext'

type SendMessageFormProps = React.FormHTMLAttributes<HTMLFormElement> & Partial<Pick<CommonAPIResponse, 'chatRoomId'>>

type SendMessageFormProps = React.FormHTMLAttributes<HTMLFormElement> & {
chatCount: number
} & Partial<Pick<CommonAPIResponse, 'chatRoomId'>>
export default function SendMessageForm({ chatRoomId, ...rest }: SendMessageFormProps) {
const { publish } = useWebSocket()
const sendMessage = (message: string) => {
console.log(`채팅방 번호 ${chatRoomId}로 채팅을 보냅니다.`)
publish(`/pub/api/v1/chat/message`, { chatRoomId, message })
}

export default function SendMessageForm({ chatRoomId, chatCount, ...rest }: SendMessageFormProps) {
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (rest.onSubmit) {
rest.onSubmit(e)
return
}

if (chatCount === 0) {
//* 채팅방 생성
await createChatRoom({ opponentMemberId: 123 })
}
//* 채팅 전송 웹소켓
console.log('chatRoomId:', chatRoomId)
const $form = e.target as HTMLFormElement
const formData = new FormData($form)
const message = formData.get('message') as string
if (!message.trim()) return
sendMessage(message)
$form.reset()
}

return (
<S.SendMessageForm onSubmit={onSubmit} {...rest}>
<S.ChatInput placeholder='채팅 내용 입력' />
<S.ChatInput placeholder='채팅 내용 입력' name='message' autoFocus />
<S.SendBtn>
<Typo14 $weight='700'>전송</Typo14>
</S.SendBtn>
Expand Down
Loading

0 comments on commit dc7bb76

Please sign in to comment.