Skip to content

Commit

Permalink
알림 key값에 따라 NotificationItem 컴포넌트 구체화 (#237)
Browse files Browse the repository at this point in the history
* feat: 알림 관련 tanstack query hook 구현

Co-authored-by: 이진욱 <[email protected]>

* feat: 알림 모델 타입 정의

Co-authored-by: 이진욱 <[email protected]>

* design: NotificationItem 컴포넌트 스타일 수정

Co-authored-by: Minjae Kim <[email protected]>

* feat: CrewNotificationItem 컴포넌트 구현

Co-authored-by: Minjae Kim <[email protected]>

* feat: GameNotificationItem 컴포넌트 구현

Co-authored-by: Minjae Kim <[email protected]>

* refactor: Alarm 타입 수정

* feat: 구체화시킨 NotificationItem 컴포넌트 페이지에 적용

* feat: 알림 request, response 타입 정의

Co-authored-by: 이진욱 <[email protected]>

* feat: 알림 api 함수 구현

Co-authored-by: 이진욱 <[email protected]>

* feat: useEventSource onerror 옵셔널로 변경

* feat: Header에서 SSE 연결
- badge 추가

* fix: 바뀐 api 명세 적용
- 타입 선언 변경
- api 함수 변경
- tanstack query hook 변경

Co-authored-by: Minjae Kim <[email protected]>

* fix: App 에서 EventSource 연결
- useEventSource 훅 변경

Co-authored-by: Minjae Kim <[email protected]>

* feat: Badge 컴포넌트 구현
- 헤더에서 EventSource 연결하던 것 제거

Co-authored-by: Minjae Kim <[email protected]>

* fix: 알림 api 명세 변경점 ui에 반영

Co-authored-by: Minjae Kim <[email protected]>

* fix: api 명세 확정 안된 부분 주석처리

* fix: alarm 조회 query hook 수정

Co-authored-by: 이진욱 <[email protected]>

* fix: alarm 조회 response type 수정

Co-authored-by: 이진욱 <[email protected]>

* feat: 알림 페이지 api 연동

Co-authored-by: 이진욱 <[email protected]>

* feat: sse 메세지 왔을 때 toast해주는 기능

* feat: 알림 관련 mutate 시 쿼리 무효화, 낙관적 업데이트

---------

Co-authored-by: 김민재 <[email protected]>
  • Loading branch information
dlwl98 and imb96 authored Nov 27, 2023
1 parent be58164 commit a199ed0
Show file tree
Hide file tree
Showing 26 changed files with 467 additions and 16 deletions.
30 changes: 28 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Toaster } from 'react-hot-toast';
import toast, { Toaster } from 'react-hot-toast';
import { RouterProvider } from 'react-router-dom';

import { ThemeProvider } from '@emotion/react';
import { QueryClient } from '@tanstack/react-query';
import { InfiniteData, QueryClient } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

import { router } from '@routes/router';

import { useEventSource } from '@hooks/useEventSource';

import GlobalStyle from '@styles/globalStyle';
import { theme } from '@styles/theme';

import { GetAlarmsResponse } from '@type/api/alarm';

const queryClient = new QueryClient({
defaultOptions: {
queries: {
Expand All @@ -21,6 +25,28 @@ const queryClient = new QueryClient({
});

function App() {
useEventSource(
'/api/alarms/subscribe',
() => {
queryClient.resetQueries({ queryKey: ['alarms'] });
queryClient
.invalidateQueries({ queryKey: ['alarms-unread'] })
.then(() => {
const data = queryClient.getQueryData<
InfiniteData<GetAlarmsResponse, number>
>(['alarms']);
const alarms = data?.pages.flatMap((page) => page.alarmResponse);
alarms &&
toast(
'crewId' in alarms[0]
? alarms[0].crewAlarmMessage
: alarms[0].gameAlarmMessage
);
});
},
(error) => console.log(error)
);

return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
Expand Down
5 changes: 5 additions & 0 deletions src/api/alarms/deleteAlarms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { axiosInstance } from '@api/axiosInstance';

export const deleteAlarms = async () => {
await axiosInstance.delete('/alarms');
};
14 changes: 14 additions & 0 deletions src/api/alarms/getAlarms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { axiosInstance } from '@api/axiosInstance';

import { GetAlarmsRequest, GetAlarmsResponse } from '@type/api/alarm';

export const getAlarms = async ({ cursorId, size }: GetAlarmsRequest) => {
const { data } = await axiosInstance.get<GetAlarmsResponse>('/alarms', {
params: {
cursorId,
size,
},
});

return data;
};
10 changes: 10 additions & 0 deletions src/api/alarms/getAlarmsUnread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { axiosInstance } from '@api/axiosInstance';

import { GetAlarmsUnreadResponse } from '@type/api/alarm';

export const getAlarmsUnread = async () => {
const { data } =
await axiosInstance.get<GetAlarmsUnreadResponse>('/alarms/unread');

return data;
};
7 changes: 7 additions & 0 deletions src/api/alarms/patchCrewAlarms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { axiosInstance } from '@api/axiosInstance';

export const patchCrewAlarms = async (alarmId: number) => {
await axiosInstance.patch(`/crew-alarm/${alarmId}`, {
isRead: true,
});
};
7 changes: 7 additions & 0 deletions src/api/alarms/patchGameAlarms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { axiosInstance } from '@api/axiosInstance';

export const patchGameAlarms = async (alarmId: number) => {
await axiosInstance.patch(`/game-alarm/${alarmId}`, {
isRead: true,
});
};
11 changes: 11 additions & 0 deletions src/components/Header/Badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useAlarmsUnreadQuery } from '@hooks/queries/useAlarmsUnreadQuery';

import { BadgeIcon } from './Header.style';

export const Badge = () => {
const { data } = useAlarmsUnreadQuery();
if (!data.unread) {
return null;
}
return <BadgeIcon />;
};
14 changes: 14 additions & 0 deletions src/components/Header/Header.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,17 @@ export const RightSideIcon = styled.button`
export const LoginButton = styled(Button)`
white-space: nowrap;
`;

export const BellIcon = styled(RightSideIcon)`
position: relative;
`;

export const BadgeIcon = styled.div`
background-color: ${({ theme }) => theme.PALETTE.RED_600};
position: absolute;
top: 2px;
right: 2px;
width: 8px;
height: 8px;
border-radius: 50%;
`;
7 changes: 5 additions & 2 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import leftArrowIcon from '@assets/leftArrow.svg';
import logoSvg from '@assets/logoSvg.svg';
import searchIcon from '@assets/search.svg';

import { Badge } from './Badge';
import {
BackwardIcon,
BackwardWrapper,
BellIcon,
HeaderBackground,
HeaderContainer,
LoginButton,
Expand Down Expand Up @@ -97,9 +99,10 @@ export const Header = ({
</RightSideIcon>
</RightSideIconWrapper>
<RightSideIconWrapper>
<RightSideIcon onClick={() => handleBellIconClick()}>
<BellIcon onClick={() => handleBellIconClick()}>
<img src={bellIcon} alt="" />
</RightSideIcon>
<Badge />
</BellIcon>
</RightSideIconWrapper>
<RightSideIconWrapper>
<RightSideIcon onClick={() => handleProfileIconClick()}>
Expand Down
12 changes: 7 additions & 5 deletions src/components/NotificationItem/NotificationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { NotificationItemMatchStatus } from './components/NotificationItemMatchS
type NotificationItemProps = {
box: React.ReactNode;
title: string;
content: React.ReactNode;
content: string;
read?: boolean;
createdAt: Date;
onClick: VoidFunction;
Expand All @@ -33,15 +33,17 @@ const NotificationItem = ({
{box}
<Flex direction="column" gap={5}>
<Flex gap={5}>
<Text size={12} weight={700}>
<Text size={12} weight={700} nowrap>
{title}
</Text>
<AgoText size={8} weight={300}>
<AgoText size={8} weight={300} nowrap>
{createdAtToString(createdAt)}
{read && <Badge />}
{!read && <Badge />}
</AgoText>
</Flex>
{content}
<Text size={14} weight={300}>
{content}
</Text>
</Flex>
</NotificationItemWrapper>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const MatchStartTime = styled.span`
font-size: 12px;
font-weight: 700;
line-height: 143%;
white-space: nowrap;
`;

export const MatchDuration = styled.span`
Expand Down
16 changes: 16 additions & 0 deletions src/hooks/mutations/useAlarmsDeleteMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { deleteAlarms } from '@api/alarms/deleteAlarms';

export const useAlarmsDeleteMutation = () => {
const queryClient = useQueryClient();

return useMutation({
mutationKey: ['alarms-delete'],
mutationFn: deleteAlarms,
onSuccess: () => {
queryClient.resetQueries({ queryKey: ['alarms'] });
queryClient.resetQueries({ queryKey: ['alarms-unread'] });
},
});
};
57 changes: 57 additions & 0 deletions src/hooks/mutations/useCrewAlarmsPatchMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
InfiniteData,
QueryKey,
useMutation,
useQueryClient,
} from '@tanstack/react-query';

import { patchCrewAlarms } from '@api/alarms/patchCrewAlarms';

import { GetAlarmsResponse } from '@type/api/alarm';

export const useCrewAlarmsPatchMutation = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (alarmId: number) => patchCrewAlarms(alarmId),
onSuccess: (_, alarmId) => {
queryClient.invalidateQueries({ queryKey: ['alarms-unread'] });

const previousAlarms = queryClient.getQueryData<
InfiniteData<GetAlarmsResponse, number>
>(['alarms']);

if (previousAlarms) {
const { pages, pageParams } = previousAlarms;
const newPages: GetAlarmsResponse[] = pages.map((page) => {
const alarm = page.alarmResponse.find((alarm) => {
if (!('crewId' in alarm)) {
return false;
}
return alarm.crewId === alarmId;
});
if (!alarm) {
return page;
}
return {
...page,
alarmResponse: page.alarmResponse.map((alarm) => {
if (!('crewId' in alarm)) {
return alarm;
}
if (alarm.crewId === alarmId) {
return { ...alarm, status: 'read' };
}
return alarm;
}),
};
});
queryClient.setQueryData<
typeof previousAlarms,
QueryKey,
typeof previousAlarms
>(['alarms'], { pageParams, pages: newPages });
}
},
});
};
57 changes: 57 additions & 0 deletions src/hooks/mutations/useGameAlarmsPatchMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
InfiniteData,
QueryKey,
useMutation,
useQueryClient,
} from '@tanstack/react-query';

import { patchGameAlarms } from '@api/alarms/patchGameAlarms';

import { GetAlarmsResponse } from '@type/api/alarm';

export const useGameAlarmsPatchMutation = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (alarmId: number) => patchGameAlarms(alarmId),
onSuccess: (_, alarmId) => {
queryClient.invalidateQueries({ queryKey: ['alarms-unread'] });

const previousAlarms = queryClient.getQueryData<
InfiniteData<GetAlarmsResponse, number>
>(['alarms']);

if (previousAlarms) {
const { pages, pageParams } = previousAlarms;
const newPages: GetAlarmsResponse[] = pages.map((page) => {
const alarm = page.alarmResponse.find((alarm) => {
if (!('gameId' in alarm)) {
return false;
}
return alarm.gameId === alarmId;
});
if (!alarm) {
return page;
}
return {
...page,
alarmResponse: page.alarmResponse.map((alarm) => {
if (!('gameId' in alarm)) {
return alarm;
}
if (alarm.gameId === alarmId) {
return { ...alarm, status: 'read' };
}
return alarm;
}),
};
});
queryClient.setQueryData<
typeof previousAlarms,
QueryKey,
typeof previousAlarms
>(['alarms'], { pageParams, pages: newPages });
}
},
});
};
24 changes: 24 additions & 0 deletions src/hooks/queries/useAlarmsQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';

import { getAlarms } from '@api/alarms/getAlarms';

import { FETCH_SIZE } from '@consts/network';

export const useAlarmsQuery = () => {
return useSuspenseInfiniteQuery({
queryKey: ['alarms'],
queryFn: ({ pageParam }) =>
getAlarms({
cursorId: pageParam !== 0 ? pageParam : undefined,
size: FETCH_SIZE,
}),

getNextPageParam: (lastPage) => {
if (!lastPage.hasNext) {
return undefined;
}
return lastPage.cursorId;
},
initialPageParam: 0,
});
};
10 changes: 10 additions & 0 deletions src/hooks/queries/useAlarmsUnreadQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useSuspenseQuery } from '@tanstack/react-query';

import { getAlarmsUnread } from '@api/alarms/getAlarmsUnread';

export const useAlarmsUnreadQuery = () => {
return useSuspenseQuery({
queryKey: ['alarms-unread'],
queryFn: getAlarmsUnread,
});
};
10 changes: 6 additions & 4 deletions src/hooks/useEventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useLoginInfoStore } from '@stores/loginInfo.store';
export const useEventSource = (
subscribeUrl: string,
onmessage: EventSourcePolyfill['onmessage'],
onerror: EventSourcePolyfill['onerror']
onerror?: EventSourcePolyfill['onerror']
) => {
const loginInfo = useLoginInfoStore((state) => state.loginInfo);

Expand All @@ -18,12 +18,14 @@ export const useEventSource = (

const EventSource = EventSourcePolyfill || NativeEventSource;
const eventSource = new EventSource(subscribeUrl, {
headers: { Authorization: loginInfo.accessToken },
headers: {
Authorization: `Bearer ${loginInfo.accessToken}`,
'Content-type': 'text/event-stream',
},
});

eventSource.onmessage = onmessage;

eventSource.onerror = onerror;
onerror && (eventSource.onerror = onerror);

return () => {
eventSource.close();
Expand Down
Loading

0 comments on commit a199ed0

Please sign in to comment.