diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6ecc939a..0ca4cf3b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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', }, } diff --git a/package.json b/package.json index d1e3b758..1b4a6784 100644 --- a/package.json +++ b/package.json @@ -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}\"", diff --git a/src/App.tsx b/src/App.tsx index afcd894f..6657a9fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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' @@ -18,29 +17,27 @@ function App() { const toggleTheme = () => setTheme(prev => (prev === lightTheme ? darkTheme : lightTheme)) return ( <> - + - - - DDang - - - - - - }> - - - - - - + + DDang + + + + + + }> + + + + + - + ) } diff --git a/src/WebSocketContext.tsx b/src/WebSocketContext.tsx index c637ab81..bb615fd0 100644 --- a/src/WebSocketContext.tsx +++ b/src/WebSocketContext.tsx @@ -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 { @@ -11,12 +11,13 @@ interface WebSocketContextType { const WebSocketContext = createContext(null) +const token = localStorage.getItem('token') + export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => { const [client, setClient] = useState(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) @@ -62,6 +63,9 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = client.publish({ destination, body: JSON.stringify(body), + headers: { + Authorization: `Bearer ${token}`, + }, }) } } diff --git a/src/apis/chat/fetchChatMessageList.tsx b/src/apis/chat/fetchChatMessageList.tsx new file mode 100644 index 00000000..8fb1119e --- /dev/null +++ b/src/apis/chat/fetchChatMessageList.tsx @@ -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 + +export type FetchChatMessageListResponse = PaginationResponse & { + content: Array< + Pick + > +} + +export const fetchChatMessageList = async ({ + chatRoomId, + lastMessageCreatedAt, +}: FetchChatMessageListRequest): Promise> => { + try { + const { data } = await axiosInstance.get>(`/chat/message/${chatRoomId}`, { + params: { lastMessageCreatedAt }, + }) + return data + } catch (error) { + if (error instanceof AxiosError) { + const { response } = error as AxiosError + + 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('다시 시도해주세요') + } +} diff --git a/src/apis/chat/useChatMessageList.tsx b/src/apis/chat/useChatMessageList.tsx new file mode 100644 index 00000000..3c00adf1 --- /dev/null +++ b/src/apis/chat/useChatMessageList.tsx @@ -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: '', + }) +} diff --git a/src/apis/chatRoom/useChatList.ts b/src/apis/chatRoom/useChatList.ts new file mode 100644 index 00000000..402ca6db --- /dev/null +++ b/src/apis/chatRoom/useChatList.ts @@ -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), + }) +} diff --git a/src/apis/chatRoom/useCreateChatRoom.tsx b/src/apis/chatRoom/useCreateChatRoom.tsx index 1a20e751..db10b6b0 100644 --- a/src/apis/chatRoom/useCreateChatRoom.tsx +++ b/src/apis/chatRoom/useCreateChatRoom.tsx @@ -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, @@ -12,12 +13,16 @@ export const useCreateChatRoom = (): UseMutationResult< createRoom: (req: CreateChatRoomRequest) => void } => { const { pushModal } = useModalStore() + const { isConnected } = useWebSocket() const mutation = useMutation, Error, CreateChatRoomRequest>({ mutationFn: createChatRoom, onSuccess: (data, { opponentMemberId }) => { + if (!isConnected) { + throw new Error('WebSocket 연결이 되어 있지 않습니다.') + } console.log(data.message || '채팅방 생성 성공') - pushModal() + pushModal() }, onError: error => { console.error('채팅방 생성 실패:', error.message) diff --git a/src/apis/chatRoom/useFriendList.ts b/src/apis/chatRoom/useFriendList.ts new file mode 100644 index 00000000..cf441129 --- /dev/null +++ b/src/apis/chatRoom/useFriendList.ts @@ -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), + }) +} diff --git a/src/apis/chatRoom/useSocialData.tsx b/src/apis/chatRoom/useSocialData.tsx deleted file mode 100644 index 3d45bab3..00000000 --- a/src/apis/chatRoom/useSocialData.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useSuspenseQueries } from '@tanstack/react-query' -import { fetchChatRoomList } from '~apis/chatRoom/fetchChatRoomList' -import { fetchFriendList } from '~apis/friend/fetchFriendList' -import { queryKey } from '~constants/queryKey' - -export function useSocialData() { - const results = useSuspenseQueries({ - queries: [ - { - queryKey: queryKey.social.chatRoomList(), - queryFn: () => fetchChatRoomList().then(res => res.data), - }, - { - queryKey: queryKey.social.friendList(), - queryFn: () => fetchFriendList().then(res => res.data), - }, - ], - }) - - const [chatListQuery, friendListQuery] = results - - return { - chatList: chatListQuery.data ?? [], - friendList: friendListQuery.data ?? [], - isLoading: chatListQuery.isLoading || friendListQuery.isLoading, - isError: chatListQuery.isError || friendListQuery.isError, - } -} diff --git a/src/apis/friend/requestFriend.ts b/src/apis/friend/requestFriend.ts new file mode 100644 index 00000000..59ea960f --- /dev/null +++ b/src/apis/friend/requestFriend.ts @@ -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> => { + const { data } = await axiosInstance.post>(`/friend`, req) + return data +} diff --git a/src/apis/main/fetchHomePageData.ts b/src/apis/main/fetchHomePageData.ts index 417e87e7..c7eaa491 100644 --- a/src/apis/main/fetchHomePageData.ts +++ b/src/apis/main/fetchHomePageData.ts @@ -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> => { diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx index 1f158ceb..6cdd4b1a 100644 --- a/src/components/Footer/index.tsx +++ b/src/components/Footer/index.tsx @@ -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: '홈' }, @@ -34,6 +35,11 @@ export default function Footer() { } navigate(endpoint) } + const { data } = useChatList() + let sum = 0 + data?.forEach(({ unreadMessageCount }) => { + sum += unreadMessageCount + }) return ( @@ -47,6 +53,7 @@ export default function Footer() { handleNavigation(endpoint) }} > + {typo === '소셜' && {sum}} {typo} diff --git a/src/components/Footer/styles.ts b/src/components/Footer/styles.ts index c44af6fe..b922fa1a 100644 --- a/src/components/Footer/styles.ts +++ b/src/components/Footer/styles.ts @@ -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` @@ -18,6 +19,7 @@ export const FooterNavList = styled.ul` ` export const FooterNavItem = styled(Link)` + position: relative; flex: 1; display: flex; flex-direction: column; @@ -25,3 +27,14 @@ export const FooterNavItem = styled(Link)` 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%; +` diff --git a/src/components/SendMessageForm/index.tsx b/src/components/SendMessageForm/index.tsx index 94fc5a6d..08478c6a 100644 --- a/src/components/SendMessageForm/index.tsx +++ b/src/components/SendMessageForm/index.tsx @@ -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 & Partial> -type SendMessageFormProps = React.FormHTMLAttributes & { - chatCount: number -} & Partial> +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) => { 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 ( - + 전송 diff --git a/src/components/UnreadChatCount.ts b/src/components/UnreadChatCount.ts new file mode 100644 index 00000000..a4ceec15 --- /dev/null +++ b/src/components/UnreadChatCount.ts @@ -0,0 +1,17 @@ +import styled from 'styled-components' + +export const UnreadChatCount = styled.span` + position: absolute; + right: 20px; + display: block; + border-radius: 22px; + background-color: ${({ theme }) => theme.colors.brand.sub}; + min-width: 20px; + + padding: 1.5px 6px; + color: ${({ theme }) => theme.colors.grayscale.gc_4}; + + display: flex; + justify-content: center; + align-items: center; +` diff --git a/src/constants/familyRole.ts b/src/constants/familyRole.ts index b03254f6..ace24a22 100644 --- a/src/constants/familyRole.ts +++ b/src/constants/familyRole.ts @@ -7,4 +7,5 @@ export const FAMILY_ROLE = { OLDER_SISTER: '누나', GRANDFATHER: '할아버지', GRANDMOTHER: '할머니', + '': '', } diff --git a/src/constants/queryKey.ts b/src/constants/queryKey.ts index f508dcf9..602a686c 100644 --- a/src/constants/queryKey.ts +++ b/src/constants/queryKey.ts @@ -2,6 +2,7 @@ export const queryKey = { social: { chatRoomList: () => ['chatRoomList'], friendList: () => ['friendList'], + chatMessageList: (chatRoomId: number) => ['chatMessageList', chatRoomId], }, profile: (memberId: number) => ['profile', memberId], home: () => ['homePageData'], diff --git a/src/hooks/useScrollPreservation.ts b/src/hooks/useScrollPreservation.ts new file mode 100644 index 00000000..05397422 --- /dev/null +++ b/src/hooks/useScrollPreservation.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef, useState } from 'react' + +type useScrollPreservationProps = { + dependency: unknown[] +} + +export function useScrollPreservation({ dependency }: useScrollPreservationProps) { + const elementRef = useRef(null) + const [prevScrollHeight, setPrevScrollHeight] = useState(0) + + const preserveScroll = () => { + if (elementRef.current) { + setPrevScrollHeight(elementRef.current.scrollHeight) + } + } + + useEffect(() => { + if (elementRef.current) { + const { scrollHeight, scrollTop } = elementRef.current + const nextScrollTop = scrollHeight - prevScrollHeight + scrollTop + elementRef.current.scrollTop = nextScrollTop + } + }, [prevScrollHeight, ...dependency]) + + return { elementRef, preserveScroll } +} diff --git a/src/modals/ChatArea/index.tsx b/src/modals/ChatArea/index.tsx new file mode 100644 index 00000000..7fdde944 --- /dev/null +++ b/src/modals/ChatArea/index.tsx @@ -0,0 +1,60 @@ +import useChatMessageList from '~apis/chat/useChatMessageList' +import { useHomePageData } from '~apis/main/useHomePageData' +import { IncomingMessage, OutgoingMessage } from '~components/Message' +import SendMessageForm from '~components/SendMessageForm' +import useObserver from '~hooks/useObserver' +import { useScrollPreservation } from '~hooks/useScrollPreservation' +import * as S from './styles' + +type ChatAreaListProps = { + chatRoomId: number +} + +export default function ChatArea({ chatRoomId }: ChatAreaListProps) { + const { + data: { memberId }, + } = useHomePageData() + const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = useChatMessageList({ chatRoomId }) + const { elementRef: chatAreaRef, preserveScroll } = useScrollPreservation({ dependency: [data] }) + const { observerRef } = useObserver({ + callback: () => { + if (hasNextPage && !isFetchingNextPage) { + if (chatAreaRef.current) { + preserveScroll() + } + fetchNextPage() + } + }, + }) + + return ( + + + {[...data.pages].reverse().map(page => + page.data.content.map(chat => + chat.memberInfo?.memberId === memberId ? ( + + {chat.text} + + ) : ( + + {chat.text} + + ) + ) + )} + + + + ) +} diff --git a/src/modals/ChatArea/styles.ts b/src/modals/ChatArea/styles.ts new file mode 100644 index 00000000..c974fd14 --- /dev/null +++ b/src/modals/ChatArea/styles.ts @@ -0,0 +1,7 @@ +import { styled } from 'styled-components' + +export const ChatArea = styled.div` + height: calc(100% - 64px); + overflow: auto; +` +export const ChatMessageList = styled.div`` diff --git a/src/modals/ChatModal/index.tsx b/src/modals/ChatModal/index.tsx index fee98bed..32f7745a 100644 --- a/src/modals/ChatModal/index.tsx +++ b/src/modals/ChatModal/index.tsx @@ -1,296 +1,69 @@ import { HiEllipsisVertical } from 'react-icons/hi2' import Header from '~components/Header' -import { IncomingMessage, OutgoingMessage } from '~components/Message' import Profile from '~components/Profile' -import SendMessageForm from '~components/SendMessageForm' import { Separator } from '~components/Separator' import { Typo11, Typo15 } from '~components/Typo' -import { useScrollToBottom } from '~hooks/useScrollToBottom' +import ChatArea from '~modals/ChatArea' import { useModalStore } from '~stores/modalStore' import * as S from './styles' +import { useFetchProfile } from '~apis/member/useFetchProfile' +import { QueryErrorResetBoundary } from '@tanstack/react-query' +import { ErrorBoundary } from 'react-error-boundary' +import { Suspense } from 'react' +import Loader from '~components/Loader' +import ErrorFallback from '~components/ErrorFallback' +import { FAMILY_ROLE } from '~constants/familyRole' type ChatModalProps = { chatRoomId: number - userId: number + opponentMemberId: number } -export default function ChatModal({ chatRoomId, userId }: ChatModalProps) { - const { popModal } = useModalStore() - console.log('chatRoomId', chatRoomId) //todo fetch by chatRoomId - console.log('userId', userId) //todo fetch by userId - const ref = useScrollToBottom() - +export default function ChatModal({ chatRoomId, opponentMemberId }: ChatModalProps) { return ( - -
- - - - 감자탕수육 - - 남자 - - 할아버지 - - - - - - -
- - {chatDummyData.map(chat => - chat.sender === '나' ? ( - {chat.message} - ) : ( - {chat.message} - ) - )} - - + + + + }> + + + + + }> + + + + ) } -const chatDummyData = [ - { - id: 0, - sender: '나', - receiver: '이서연', - message: '안녕하세요! 지난번 회의 자료 보내주실 수 있나요?', - timestamp: '2024-11-15T09:30:01.936376', - isRead: true, - }, - { - id: 1, - sender: '이서연', - receiver: '나', - message: '네! 잠시만 기다려주세요. 바로 찾아보겠습니다.', - timestamp: '2024-11-15T09:31:01.936400', - isRead: true, - }, - { - id: 2, - sender: '이서연', - receiver: '나', - message: '[회의자료.pdf] 첨부파일 보내드립니다!', - timestamp: '2024-11-15T09:33:01.936409', - isRead: true, - }, - { - id: 3, - sender: '나', - receiver: '이서연', - message: '감사합니다 😊', - timestamp: '2024-11-15T09:34:01.936416', - isRead: true, - }, - { - id: 4, - sender: '나', - receiver: '이서연', - message: '그런데 이번 프로젝트 일정이 어떻게 되나요?', - timestamp: '2024-11-15T09:35:01.936423', - isRead: true, - }, - { - id: 5, - sender: '이서연', - receiver: '나', - message: '다음 주 월요일부터 시작이에요!', - timestamp: '2024-11-15T09:36:01.936430', - isRead: true, - }, - { - id: 6, - sender: '나', - receiver: '이서연', - message: '알겠습니다. 혹시 킥오프 미팅은 몇 시인가요?', - timestamp: '2024-11-15T09:37:01.936438', - isRead: true, - }, - { - id: 7, - sender: '이서연', - receiver: '나', - message: '오전 10시에 회의실 3번에서 진행됩니다.', - timestamp: '2024-11-15T09:38:01.936445', - isRead: true, - }, - { - id: 8, - sender: '나', - receiver: '이서연', - message: '네, 참석하겠습니다!', - timestamp: '2024-11-15T09:39:01.936452', - isRead: true, - }, - { - id: 9, - sender: '이서연', - receiver: '나', - message: '그리고 팀원 명단도 보내드릴게요.', - timestamp: '2024-11-15T09:40:01.936458', - isRead: true, - }, - { - id: 10, - sender: '이서연', - receiver: '나', - message: '[팀원명단.xlsx] 여기 있습니다!', - timestamp: '2024-11-15T09:41:01.936465', - isRead: true, - }, - { - id: 11, - sender: '나', - receiver: '이서연', - message: '혹시 사전 준비해야 할 사항이 있을까요?', - timestamp: '2024-11-15T09:42:01.936471', - isRead: true, - }, - { - id: 12, - sender: '이서연', - receiver: '나', - message: '간단한 자기소개만 준비해 오시면 됩니다!', - timestamp: '2024-11-15T09:43:01.936478', - isRead: true, - }, - { - id: 13, - sender: '나', - receiver: '이서연', - message: '알겠습니다. 점심 식사는 어떻게 하나요?', - timestamp: '2024-11-15T09:44:01.936485', - isRead: true, - }, - { - id: 14, - sender: '이서연', - receiver: '나', - message: '회사에서 도시락을 준비한다고 합니다.', - timestamp: '2024-11-15T09:45:01.936491', - isRead: true, - }, - { - id: 15, - sender: '나', - receiver: '이서연', - message: '아, 그렇군요. 좋네요 👍', - timestamp: '2024-11-15T09:46:01.936497', - isRead: false, - }, - { - id: 16, - sender: '이서연', - receiver: '나', - message: '혹시 음료 선호도 있으신가요?', - timestamp: '2024-11-15T09:47:01.936504', - isRead: false, - }, - { - id: 17, - sender: '나', - receiver: '이서연', - message: '아메리카노 좋아합니다!', - timestamp: '2024-11-15T09:48:01.936511', - isRead: false, - }, - { - id: 18, - sender: '이서연', - receiver: '나', - message: '네, 참고하겠습니다 😊', - timestamp: '2024-11-15T09:49:01.936517', - isRead: false, - }, - { - id: 19, - sender: '나', - receiver: '이서연', - message: '회의실에 프로젝터 있나요?', - timestamp: '2024-11-15T09:50:01.936523', - isRead: false, - }, - { - id: 20, - sender: '이서연', - receiver: '나', - message: '네, 프로젝터랑 화이트보드 모두 구비되어 있습니다.', - timestamp: '2024-11-15T09:51:01.936530', - isRead: false, - }, - { - id: 21, - sender: '나', - receiver: '이서연', - message: '노트북은 개인이 준비하면 되나요?', - timestamp: '2024-11-15T09:52:01.936536', - isRead: false, - }, - { - id: 22, - sender: '이서연', - receiver: '나', - message: '네, 개인 노트북 지참해 주세요!', - timestamp: '2024-11-15T09:53:01.936542', - isRead: false, - }, - { - id: 23, - sender: '나', - receiver: '이서연', - message: '와이파이 비밀번호는 어떻게 되나요?', - timestamp: '2024-11-15T09:54:01.936549', - isRead: false, - }, - { - id: 24, - sender: '이서연', - receiver: '나', - message: '당일 회의실에 비치해두겠습니다.', - timestamp: '2024-11-15T09:55:01.936555', - isRead: false, - }, - { - id: 25, - sender: '나', - receiver: '이서연', - message: '알겠습니다. 다른 준비사항 있으면 알려주세요!', - timestamp: '2024-11-15T09:56:01.936562', - isRead: false, - }, - { - id: 26, - sender: '이서연', - receiver: '나', - message: '네, 추가 사항 있으면 다시 연락드리겠습니다.', - timestamp: '2024-11-15T09:57:01.936569', - isRead: false, - }, - { - id: 27, - sender: '나', - receiver: '이서연', - message: '감사합니다. 좋은 하루 보내세요!', - timestamp: '2024-11-15T09:58:01.936575', - isRead: false, - }, - { - id: 28, - sender: '이서연', - receiver: '나', - message: '네, 과장님도 좋은 하루 되세요 😊', - timestamp: '2024-11-15T09:59:01.936582', - isRead: false, - }, - { - id: 29, - sender: '나', - receiver: '이서연', - message: '월요일에 뵙겠습니다!', - timestamp: '2024-11-15T10:00:01.936589', - isRead: false, - }, -] +type ChatModalHeaderProps = { + opponentMemberId: number +} + +function ChatModalHeader({ opponentMemberId }: ChatModalHeaderProps) { + const { + data: { name, gender, familyRole }, + } = useFetchProfile(opponentMemberId) + const { popModal } = useModalStore() + + return ( +
+ + + + {name} + + {gender === 'MALE' ? '남자' : '여자'} + + {FAMILY_ROLE[familyRole]} + + + + + + +
+ ) +} diff --git a/src/modals/ChatModal/styles.ts b/src/modals/ChatModal/styles.ts index a49d1e45..4582f538 100644 --- a/src/modals/ChatModal/styles.ts +++ b/src/modals/ChatModal/styles.ts @@ -8,7 +8,6 @@ export const ChatModal = styled.div` height: 100%; padding: ${HEADER_HEIGHT_LG}px 20px 0; background-color: ${({ theme }) => theme.colors.brand.lighten_3}; - overflow: auto; &::after { background: url(${DogHowling}) center/cover; content: ''; diff --git a/src/pages/HomePage/index.tsx b/src/pages/HomePage/index.tsx index 6079e28e..df6c11dc 100644 --- a/src/pages/HomePage/index.tsx +++ b/src/pages/HomePage/index.tsx @@ -1,12 +1,13 @@ -import { QueryErrorResetBoundary } from '@tanstack/react-query' -import { Suspense } from 'react' +import { InfiniteData, QueryErrorResetBoundary, useQueryClient } from '@tanstack/react-query' +import { Suspense, useEffect } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { Helmet } from 'react-helmet-async' -import BellIcon from '~assets/icons/bell_icon.svg?react' -import GPSIcon from '~assets/icons/gps_icon.svg?react' -import ClockIcon from '~assets/icons/clock_icon.svg?react' +import { useNavigate, useSearchParams } from 'react-router-dom' import { useHomePageData } from '~apis/main/useHomePageData' import DogHand from '~assets/dog_hand.svg?react' +import BellIcon from '~assets/icons/bell_icon.svg?react' +import ClockIcon from '~assets/icons/clock_icon.svg?react' +import GPSIcon from '~assets/icons/gps_icon.svg?react' import { ActionButton } from '~components/Button/ActionButton' import ErrorFallback from '~components/ErrorFallback' import Loader from '~components/Loader' @@ -17,13 +18,109 @@ import { FAMILY_ROLE } from '~constants/familyRole' import NotificationModal from '~modals/NotificationModal' import { useModalStore } from '~stores/modalStore' import * as S from './styles' -import { useSearchParams, useNavigate } from 'react-router-dom' -import { useEffect } from 'react' +import { useWebSocket } from '~/WebSocketContext' +import { FetchChatMessageListResponse } from '~apis/chat/fetchChatMessageList' +import { queryKey } from '~constants/queryKey' +import { APIResponse, CommonAPIResponse } from '~types/api' function HomeContent() { + const { isConnected, subscribe } = useWebSocket() const { data } = useHomePageData() - console.log(data) const { pushModal } = useModalStore() + const queryClient = useQueryClient() + + useEffect(() => { + if (isConnected) { + console.log('구독!') + subscribe(`/user/queue/errors`, message => { + const response = JSON.parse(message.body) + console.log('에러 구독', response) + }) + + subscribe(`/sub/message/${data.email}`, message => { + const response = JSON.parse(message.body) as { + data: { + chatRoomId: number + unreadCount: number + }[] + } + console.log('이메일 구독', response) + response.data.forEach(chatRoom => { + subscribe(`/sub/chat/${chatRoom.chatRoomId}`, message => { + const res = JSON.parse(message.body) as APIResponse< + Pick< + CommonAPIResponse, + 'chatId' | 'createdAt' | 'updatedAt' | 'chatRoomId' | 'memberInfo' | 'isRead' | 'text' + > + > + console.log('채팅방 구독', res) + queryClient.invalidateQueries({ + queryKey: queryKey.social.chatRoomList(), + }) + if (res.data.chatId) + queryClient.setQueryData>>( + queryKey.social.chatMessageList(res.data.chatRoomId), + oldData => { + if (!oldData) { + const initialPage: APIResponse = { + code: 200, + status: 'OK', + message: 'Success', + data: { + content: [res.data], + size: 1, + number: 0, + numberOfElements: 1, + first: true, + last: true, + empty: false, + sort: { + empty: true, + sorted: false, + unsorted: true, + }, + pageable: { + offset: 0, + sort: { + empty: true, + sorted: false, + unsorted: true, + }, + pageSize: 1, + paged: true, + pageNumber: 0, + unpaged: false, + }, + }, + } + return { + pages: [initialPage], + pageParams: [null], + } + } + return { + ...oldData, + pages: oldData.pages.map((page, index) => { + if (index === 0) { + return { + ...page, + data: { + ...page.data, + content: [...page.data.content, res.data], + numberOfElements: page.data.numberOfElements + 1, + }, + } + } + return page + }), + } + } + ) + }) + }) + }) + } + }, [isConnected]) return ( <> @@ -81,6 +178,7 @@ function HomeContent() { export default function HomePage() { const [searchParams] = useSearchParams() const navigate = useNavigate() + useEffect(() => { const accessToken = searchParams.get('accessToken') if (accessToken) { @@ -90,12 +188,15 @@ export default function HomePage() { window.history.replaceState({}, '', '/') return } + const storedToken = localStorage.getItem('token') if (!storedToken) { console.log('토큰 없음 비로그인 상태. login페이지 이동.') navigate('/login') + return } }, [searchParams, navigate]) + return ( diff --git a/src/pages/RegisterPage/Register/index.tsx b/src/pages/RegisterPage/Register/index.tsx index c339c775..82548c6a 100644 --- a/src/pages/RegisterPage/Register/index.tsx +++ b/src/pages/RegisterPage/Register/index.tsx @@ -53,6 +53,8 @@ export default function Register() { provider: registerData.provider as 'KAKAO' | 'GOOGLE', }) if (response.code === 201) { + //? 채팅 구현을 위해 임의로 추가한 부분입니다. + setOwnerProfile({ memberId: response.data.memberId }) pushModal() } } catch (error) { diff --git a/src/pages/SocialPage/components/ChatItem/index.tsx b/src/pages/SocialPage/components/ChatItem/index.tsx index 3636e995..4e2f0965 100644 --- a/src/pages/SocialPage/components/ChatItem/index.tsx +++ b/src/pages/SocialPage/components/ChatItem/index.tsx @@ -5,6 +5,7 @@ import { Separator } from '~components/Separator' import { Typo11, Typo13, Typo17 } from '~components/Typo' import * as S from './styles' import { FAMILY_ROLE } from '~constants/familyRole' +import { UnreadChatCount } from '~components/UnreadChatCount' type ChatItemProps = FetchChatRoomListResponse[number] @@ -31,9 +32,9 @@ export default function ChatItem({ lastMessage, members, name, unreadMessageCoun {lastMessage} - + {unreadMessageCount} - + ) } diff --git a/src/pages/SocialPage/components/ChatItem/styles.ts b/src/pages/SocialPage/components/ChatItem/styles.ts index d732531a..664b444f 100644 --- a/src/pages/SocialPage/components/ChatItem/styles.ts +++ b/src/pages/SocialPage/components/ChatItem/styles.ts @@ -20,19 +20,3 @@ export const DetailWrapper = styled.div` align-items: center; gap: 4px; ` - -export const UnreadChatCount = styled.span` - position: absolute; - right: 20px; - display: block; - border-radius: 22px; - background-color: ${({ theme }) => theme.colors.brand.sub}; - min-width: 20px; - - padding: 1.5px 6px; - color: ${({ theme }) => theme.colors.grayscale.gc_4}; - - display: flex; - justify-content: center; - align-items: center; -` diff --git a/src/pages/SocialPage/components/FriendChatList/index.tsx b/src/pages/SocialPage/components/FriendChatList/index.tsx index 0ac6d9cc..45eca904 100644 --- a/src/pages/SocialPage/components/FriendChatList/index.tsx +++ b/src/pages/SocialPage/components/FriendChatList/index.tsx @@ -1,4 +1,5 @@ -import { useSocialData } from '~apis/chatRoom/useSocialData' +import useChatList from '~apis/chatRoom/useChatList' +import useFriendList from '~apis/chatRoom/useFriendList' import ChatItem from '~pages/SocialPage/components/ChatItem' import FriendItem from '~pages/SocialPage/components/FriendItem' import { SocialTabs } from '~types/social' @@ -8,14 +9,26 @@ type FriendChatListProps = { selectedTab: SocialTabs } -export default function FriendChatList({ selectedTab }: FriendChatListProps) { - const { chatList, friendList } = useSocialData() +function FriendList() { + const { data: friendList } = useFriendList() return ( - {selectedTab === 'friendList' - ? friendList.map(friendInfo => ) - : chatList.map(chatInfo => )} + {friendList?.map(friendInfo => )} ) } + +function ChatList() { + const { data: chatList } = useChatList() + + return ( + + {chatList?.map(chatInfo => )} + + ) +} + +export default function FriendChatList({ selectedTab }: FriendChatListProps) { + return selectedTab === 'friendList' ? : +} diff --git a/src/router.tsx b/src/router.tsx index 55aaaf98..bda24bab 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -2,16 +2,17 @@ import Footer from '~components/Footer' import { createBrowserRouter, Outlet } from 'react-router-dom' import * as Pages from './components/LazyComponents' import ModalContainer from '~modals/ModalContainer' +import { WebSocketProvider } from '~/WebSocketContext' export const router = createBrowserRouter([ { path: '/', element: ( - <> +