diff --git a/src/App.tsx b/src/App.tsx index cc12188..d503728 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,12 +9,14 @@ import { router } from '~/router' import GlobalStyle from '~/styles/globalStyle' import { darkTheme, lightTheme } from '~/styles/theme' import PageLoader from '~components/PageLoader' +import PushNotification from '~components/PushNotification' const queryClient = new QueryClient() function App() { //* 다크모드 확장성 고려 const [theme, setTheme] = useState(lightTheme) const toggleTheme = () => setTheme(prev => (prev === lightTheme ? darkTheme : lightTheme)) + return ( <> @@ -32,6 +34,7 @@ function App() { }> + diff --git a/src/components/GlobalHookContainer/index.tsx b/src/components/GlobalHookContainer/index.tsx new file mode 100644 index 0000000..57e66ca --- /dev/null +++ b/src/components/GlobalHookContainer/index.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' +import useSubscribe from '~hooks/useSubscribe' +import useToken from '~hooks/useToken' + +export default function GlobalHookContainer({ children }: { children: ReactNode }) { + useToken() + useSubscribe() + return <>{children} +} diff --git a/src/components/PushNotification/index.tsx b/src/components/PushNotification/index.tsx new file mode 100644 index 0000000..f2bdf3e --- /dev/null +++ b/src/components/PushNotification/index.tsx @@ -0,0 +1,27 @@ +import { AnimatePresence } from 'framer-motion' +import { Typo15 } from '~components/Typo' +import { usePushNotificationStore } from '~stores/usePushNotificationStore' +import * as S from './styles' + +export default function PushNotification() { + const { notifications, clearNotification } = usePushNotificationStore() + + return ( + + {notifications.map(({ id, message }) => ( + clearNotification()} + > + + {message} + + + ))} + + ) +} diff --git a/src/components/PushNotification/styles.ts b/src/components/PushNotification/styles.ts new file mode 100644 index 0000000..63982b2 --- /dev/null +++ b/src/components/PushNotification/styles.ts @@ -0,0 +1,34 @@ +import { motion } from 'framer-motion' +import { styled } from 'styled-components' + +export const PushNotification = styled(motion.div)` + position: fixed; + left: 50%; + top: 20px; + background-color: ${({ theme }) => theme.colors.grayscale.gc_4}; + color: ${({ theme }) => theme.colors.grayscale.font_1}; + padding: 16px 36px; + translate: -50%; + border-radius: 16px; + width: 90%; + max-width: 400px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + line-height: 1.4; + z-index: 1000; + + &::before { + content: ''; + position: absolute; + left: 18px; + top: 50%; + translate: 0 -50%; + width: 3.5px; + height: 20px; + background-color: ${({ theme }) => theme.colors.brand.default}; + border-radius: 100px; + } +` diff --git a/src/components/SendMessageForm/index.tsx b/src/components/SendMessageForm/index.tsx index 08478c6..480d090 100644 --- a/src/components/SendMessageForm/index.tsx +++ b/src/components/SendMessageForm/index.tsx @@ -24,6 +24,7 @@ export default function SendMessageForm({ chatRoomId, ...rest }: SendMessageForm if (!message.trim()) return sendMessage(message) $form.reset() + $form['message'].focus() } return ( diff --git a/src/hooks/useSubscribe.ts b/src/hooks/useSubscribe.ts new file mode 100644 index 0000000..772d243 --- /dev/null +++ b/src/hooks/useSubscribe.ts @@ -0,0 +1,180 @@ +import { InfiniteData, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useWebSocket } from '~/WebSocketContext' +import { FetchChatMessageListResponse } from '~apis/chat/fetchChatMessageList' +import { fetchHomePageData } from '~apis/main/fetchHomePageData' +import { FetchNotificationListResponse } from '~apis/notification/fetchNotificationList' +import { queryKey } from '~constants/queryKey' +import { usePushNotificationStore } from '~stores/usePushNotificationStore' +import { APIResponse } from '~types/api' + +export default function useSubscribe() { + const { data } = useQuery({ + queryKey: queryKey.home(), + queryFn: () => fetchHomePageData().then(data => data.data), + enabled: !!localStorage.getItem('token'), + }) + + const { isConnected, subscribe } = useWebSocket() + const queryClient = useQueryClient() + const { showNotification } = usePushNotificationStore() + + useEffect(() => { + if (isConnected) { + 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) + if (response.code === 1000) { + //* 첫 연결 시 모든 채팅방 반환 + type Data = { + chatRoomId: number + unreadCount: number + } + const data = response.data as Data[] + + console.log('이메일 구독', response) + + data.forEach((chatRoom: Data) => { + subscribe(`/sub/chat/${chatRoom.chatRoomId}`, message => { + const res = JSON.parse(message.body) as APIResponse + 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 + }), + } + } + ) + }) + }) + } + if (response.code === 1001) { + //* 첫 연결 이후부터 새로운 채팅방 생성 시 + // const data = response.data as CreateChatRoomResponse + //todo 새로운 채팅방 추가 + queryClient.invalidateQueries({ queryKey: queryKey.social.chatRoomList() }) + } + }) + + subscribe(`/sub/notification/${data?.email || ''}`, message => { + const response = JSON.parse(message.body) as APIResponse + console.log('알림 구독', response) + if (!response.data.content) { + return + } + showNotification(response.data.content) + console.log(response) + queryClient.setQueryData>>( + queryKey.notification(), + oldData => { + if (!oldData) { + return { + pages: [ + { + code: 200, + status: 'OK', + message: 'Success', + data: { + content: [response.data], + pageable: { + offset: 0, + sort: { empty: true, sorted: false, unsorted: true }, + pageSize: 1, + paged: true, + pageNumber: 0, + unpaged: false, + }, + last: true, + size: 1, + number: 0, + sort: { empty: true, sorted: false, unsorted: true }, + first: true, + numberOfElements: 1, + empty: false, + }, + }, + ], + pageParams: [0], + } + } + + return { + ...oldData, + pages: oldData.pages.map((page, index) => + index === 0 + ? { + ...page, + data: { + ...page.data, + content: [response.data, ...page.data.content], + numberOfElements: page.data.numberOfElements + 1, + }, + } + : page + ), + } + } + ) + }) + } + }, [isConnected]) +} diff --git a/src/hooks/useToken.ts b/src/hooks/useToken.ts new file mode 100644 index 0000000..fea668b --- /dev/null +++ b/src/hooks/useToken.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' + +export default function useToken() { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + + useEffect(() => { + console.log('useToken 실행') + const accessToken = searchParams.get('accessToken') + if (accessToken) { + localStorage.setItem('token', accessToken) + console.log('토큰 가져옴(숨김처리 예정) : ', accessToken) + //URL에서 토큰 파라미터 제거하고 홈페이지로 리다이렉트, JWT토큰이 URL에 노출되어 히스토리에 남지 않게 함 + window.history.replaceState({}, '', '/') + return + } + + const storedToken = localStorage.getItem('token') + if (!storedToken) { + console.log('토큰 없음 비로그인 상태. login페이지 이동.') + navigate('/login') + return + } + }, [searchParams, navigate]) +} diff --git a/src/modals/ChatModal/index.tsx b/src/modals/ChatModal/index.tsx index 8f66145..925db3d 100644 --- a/src/modals/ChatModal/index.tsx +++ b/src/modals/ChatModal/index.tsx @@ -47,14 +47,14 @@ type ChatModalHeaderProps = { function ChatModalHeader({ opponentMemberId }: ChatModalHeaderProps) { const { - data: { name, gender, familyRole }, + data: { name, gender, familyRole, profileImg }, } = useFetchProfile(opponentMemberId) const { popModal } = useModalStore() return (
- + {name} diff --git a/src/pages/HomePage/index.tsx b/src/pages/HomePage/index.tsx index 115b62d..460c49e 100644 --- a/src/pages/HomePage/index.tsx +++ b/src/pages/HomePage/index.tsx @@ -1,11 +1,9 @@ -import { InfiniteData, QueryErrorResetBoundary, useQueryClient } from '@tanstack/react-query' -import { Suspense, useEffect } from 'react' +import { QueryErrorResetBoundary } from '@tanstack/react-query' +import { Suspense } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { Helmet } from 'react-helmet-async' -import { useNavigate, useSearchParams } from 'react-router-dom' -import { useWebSocket } from '~/WebSocketContext' -import { FetchChatMessageListResponse } from '~apis/chat/fetchChatMessageList' import { useHomePageData } from '~apis/main/useHomePageData' +import useInfiniteNotificationList from '~apis/notification/useInfiniteNotificationList' 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' @@ -17,116 +15,28 @@ import Profile from '~components/Profile' import { Separator } from '~components/Separator' import { Typo14, Typo17, Typo24 } from '~components/Typo' import { FAMILY_ROLE } from '~constants/familyRole' -import { queryKey } from '~constants/queryKey' import NotificationModal from '~modals/NotificationModal' import { useModalStore } from '~stores/modalStore' -import { APIResponse, CommonAPIResponse } from '~types/api' import * as S from './styles' function HomeContent() { - const { isConnected, subscribe } = useWebSocket() + const { + data: { pages: notificationListPages }, + } = useInfiniteNotificationList() const { data } = useHomePageData() 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]) + const unreadNotificationCount = notificationListPages.reduce((count, page) => { + return count + page.data.content.filter(noti => noti.isRead === 'FALSE').length + }, 0) return ( <> - pushModal()} /> + + pushModal()} /> + {unreadNotificationCount ? : null} + @@ -176,27 +86,6 @@ function HomeContent() { } export default function HomePage() { - const [searchParams] = useSearchParams() - const navigate = useNavigate() - - useEffect(() => { - const accessToken = searchParams.get('accessToken') - if (accessToken) { - localStorage.setItem('token', accessToken) - console.log('토큰 가져옴(숨김처리 예정) : ', accessToken) - //URL에서 토큰 파라미터 제거하고 홈페이지로 리다이렉트, JWT토큰이 URL에 노출되어 히스토리에 남지 않게 함 - 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/HomePage/styles.ts b/src/pages/HomePage/styles.ts index 6864a5e..8b393bb 100644 --- a/src/pages/HomePage/styles.ts +++ b/src/pages/HomePage/styles.ts @@ -49,3 +49,15 @@ export const WalkDistance = styled.div` align-items: center; justify-content: space-between; ` +export const BellIconWrapper = styled.div` + position: relative; +` +export const UnreadCircle = styled.span` + position: absolute; + width: 4px; + height: 4px; + background-color: ${({ theme }) => theme.colors.brand.sub}; + top: 0; + right: 0; + border-radius: 50%; +` diff --git a/src/router.tsx b/src/router.tsx index 735420a..d2252e6 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -3,15 +3,18 @@ import { WebSocketProvider } from '~/WebSocketContext' import Footer from '~components/Footer' import ModalContainer from '~modals/ModalContainer' import * as Pages from './components/LazyComponents' +import GlobalHookContainer from '~components/GlobalHookContainer' export const router = createBrowserRouter([ { path: '/', element: ( - -