Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: 알림 페이지 sse 실시간 연동 #172

Merged
merged 6 commits into from
Mar 16, 2025
Next Next commit
feat: protectedRoute.ts에 sse 연동 로직 작성 및 useOutletContext를 활용한 알림 페이지로…
… 데이터 넘겨주는 로직 작성
  • Loading branch information
jjaeho0415 committed Mar 15, 2025
commit e32c554cc1fa1251740d9ea616d54cc306ad664d
3 changes: 2 additions & 1 deletion src/api/apiRoutes.ts
Original file line number Diff line number Diff line change
@@ -31,7 +31,8 @@ const apiRoutes = {
//채팅 관련
chatRoomsList: "/chats",
// 알림 관련
readNotification: "/notifications/read"
readNotification: "/notifications/read",
notificationList: "/notifications/list"
};

export default apiRoutes;
19 changes: 19 additions & 0 deletions src/api/notification/getNotificationList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import apiRoutes from "@api/apiRoutes"
import api from "@api/fetcher"
import { useQuery } from "@tanstack/react-query";

const getNotificationList = async (authorization: string) => {
const response: IGetNotificationListResponseBodyType = await api.get({
endpoint: apiRoutes.notificationList,
authorization
})
return response;
}

export const useGetNotificationList = (authorization: string) => {
return useQuery({
queryKey: ["NOTIFICATION_LIST"],
queryFn: () => getNotificationList(authorization),
enabled: !!authorization,
})
}
38 changes: 38 additions & 0 deletions src/api/notification/getSubscribeToSSE.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { QueryClient } from "@tanstack/react-query";

export const getSubscribeToSSE = async (accessToken: string, queryClient: QueryClient) => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/notification/subscribe`, {
method: "GET",
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${accessToken}`,
},
});

if (!response.ok) {
throw new Error("SSE 연결 실패");
}

const reader = response.body?.getReader();
const decoder = new TextDecoder();

if (!reader) {
throw new Error("SSE 스트림을 읽을 수 없음");
}

while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value, { stream: true });
console.log("SSE 데이터 수신: ", chunk);
queryClient.invalidateQueries({
queryKey: ["NOTIFICATION_LIST"],
});
}
} catch (error) {
console.error("SSE 오류: ", error);
}
};
35 changes: 6 additions & 29 deletions src/pages/NotificationPage/components/NotificationItem.tsx
Original file line number Diff line number Diff line change
@@ -4,33 +4,8 @@ import DefaultNotificationIcon from "@assets/Icons/alert/defaultAlert.svg?react"
import RedDotIcon from "@assets/Icons/headers/redDot.svg?react";
import { usePostReadNotification } from "@api/notification/postReadNotification";
import useAuthStore from "@store/useAuthStore";

const notificationType = (eventType: string) => {
switch (eventType) {
case "FRIEND_REQUEST":
return "친구 요청 알림";
case "FRIEND_ACCEPT":
return "친구 요청 수락 알림";
case "SCHEDULE_REMINDER":
return "일정 알림";
case "GROUP_DELETE":
return "그룹 삭제 알림";
case "GROUP_INVITE":
return "그룹 초대 알림";
case "GROUP_ACCEPT":
return "그룹 초대 수락 알림";
case "GROUP_EXPEL":
return "그룹 강제 탈퇴 알림";
case "GROUP_SCHEDULE_DELETE":
return "그룹 일정 삭제 알림";
case "GROUP_SCHEDULE_CREATE":
return "그룹 일정 생성 알림";
case "COMMENT":
return "그룹 일정 댓글 알림";
case "BIRTHDAY":
return "생일 알림";
}
};
import { notificationType } from "../utils/notificationType";
import { getRelativeTime } from "../utils/getRelativeTime";

interface Props {
notificationItem: INotificationItemType;
@@ -42,7 +17,7 @@ const NotificationItem: React.FC<Props> = ({ notificationItem }) => {
const { mutate: readNotification } = usePostReadNotification(accessToken);

const handleNotificationClick = () => {
// readNotification(notificationItem.id);
readNotification(notificationItem.id);
navigate(`${notificationItem.relatedUrl}`);
};

@@ -54,7 +29,9 @@ const NotificationItem: React.FC<Props> = ({ notificationItem }) => {
<div className={styles.rightSection}>
<div className={styles.eventTypeText}>{notificationType(notificationItem.eventType)}</div>
<div className={styles.contentsText}>{notificationItem.contents}</div>
<div className={styles.createdTimeText}>1일 전</div>
<div className={styles.createdTimeText}>
{getRelativeTime(notificationItem.createdDate)}
</div>
{!notificationItem.read && <RedDotIcon className={styles.redDotIcon} />}
</div>
</div>
2 changes: 1 addition & 1 deletion src/pages/NotificationPage/components/NotificationList.tsx
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ const NotificationList: React.FC<Props> = ({ notificationList }) => {
<div className={styles.notificationListContainer}>
{notificationList.length > 0 ? (
notificationList.map((notificationItem) => (
<NotificationItem notificationItem={notificationItem} />
<NotificationItem notificationItem={notificationItem} key={notificationItem.id} />
))
) : (
<div className={styles.noNotificationListSection}>알림 내역이 없습니다.</div>
36 changes: 29 additions & 7 deletions src/pages/NotificationPage/page/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import HasOnlyBackArrowHeader from "@components/headers/HasOnlyBackArrowHeader"
import styles from "./notification.module.scss"
import { useNavigate } from "react-router-dom"
import HasOnlyBackArrowHeader from "@components/headers/HasOnlyBackArrowHeader";
import styles from "./notification.module.scss";
import { useNavigate, useOutletContext } from "react-router-dom";
import AlertList from "../components/NotificationList";

const notificationList: INotificationItemType[] = [
@@ -10,97 +10,119 @@ const notificationList: INotificationItemType[] = [
contents: "이수현님이 친구요청을 보냈습니다.",
read: false,
relatedUrl: "/myPage/friendsManagement",
createdDate: "2025-02-11T14:40:30",
},
{
id: 2,
eventType: "FRIEND_ACCEPT",
contents: "이상준님이 친구요청을 수락하였습니다.",
read: false,
relatedUrl: "/myPage/friendsManagement",
createdDate: "2025-02-11T14:40:30",
},
{
id: 3,
eventType: "SCHEDULE_REMINDER",
contents: "영화 보기 일정이 하루 남았습니다.",
read: false,
relatedUrl: "/mySchedule/2",
createdDate: "2025-02-11T14:40:30",
},
{
id: 4,
eventType: "SCHEDULE_REMINDER",
contents: "회의 일정 2시간 전",
read: true,
relatedUrl: "/group/1/calendar/schedule/4",
createdDate: "2025-02-11T14:40:30",
},
{
id: 5,
eventType: "GROUP_DELETE",
contents: "MyGroup 그룹이 삭제되었습니다.",
read: false,
relatedUrl: "/groupList",
createdDate: "2025-02-11T14:40:30",
},
{
id: 6,
eventType: "GROUP_INVITE",
contents: "이상준님이 그룹 초대 요청을 보냈습니다.",
read: false,
relatedUrl: "/groupList",
createdDate: "2025-02-11T14:40:30",
},
{
id: 7,
eventType: "GROUP_ACCEPT",
contents: "이상준님이 그룹 초대 요청을 수락하였습니다.",
read: false,
relatedUrl: "/group/1/members",
createdDate: "2025-02-11T14:40:30",
},
{
id: 8,
eventType: "GROUP_EXPEL",
contents: "MyGroup 그룹에서 추방되었습니다.",
read: true,
relatedUrl: "/groupList",
createdDate: "2025-02-11T14:40:30",
},
{
id: 9,
eventType: "GROUP_SCHEDULE_DELETE",
contents: "그룹 일정 '프로젝트 회의'이(가) 삭제되었습니다",
read: true,
relatedUrl: "/group/1/groupCalendar",
createdDate: "2025-02-11T14:40:30",
},
{
id: 10,
eventType: "GROUP_SCHEDULE_CREATE",
contents: "그룹 일정 '프로젝트 회의하고 술먹기'이(가) 생성되었습니다",
read: false,
relatedUrl: "/group/1/calendar/schedule/4",
createdDate: "2025-02-11T14:40:30",
},
{
id: 11,
eventType: "COMMENT",
contents: "[밥먹기] 이상준님이 댓글을 작성했습니다.",
read: false,
relatedUrl: "/group/1/calendar/schedule/4",
createdDate: "2025-02-11T14:40:30",
},
{
id: 12,
eventType: "BIRTHDAY",
contents: "오늘은 이상준님의 생일입니다.",
read: false,
relatedUrl: "/group/1/calendar/schedule/4",
createdDate: "2025-02-11T14:40:30",
},
];


const NotificationPage = () => {
const { notifications, isLoading, error } = useOutletContext<{
notifications: IGetNotificationListResponseBodyType;
isLoading: boolean;
error: Error | null;
}>();
const navigate = useNavigate();

const navigate = useNavigate();
if (isLoading) {
return <div>로딩중...</div>
}
if (error) {
return <div>오류 발생 : {error.message}</div>
}

return (
<div className={styles.mainContainer}>
<HasOnlyBackArrowHeader title="알림" handleClick={() => navigate(-1)} />
<AlertList notificationList={notificationList} />
</div>
);
}
};

export default NotificationPage
export default NotificationPage;
29 changes: 29 additions & 0 deletions src/pages/NotificationPage/utils/getRelativeTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export const getRelativeTime = (createdDate: string) => {
const now = new Date();
const created = new Date(createdDate);

const diffInSeconds = Math.floor((now.getTime() - created.getTime()) / 1000);
if (diffInSeconds < 60) {
return `${diffInSeconds}초 전`;
}

const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes}분 전`;
}

const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours}시간 전`;
}

const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 30) {
return `${diffInDays}일 전`;
}

const diffInMonths = Math.floor(diffInDays / 30);
if (diffInDays < 7) {
return `${diffInMonths}개월 전`;
}
};
26 changes: 26 additions & 0 deletions src/pages/NotificationPage/utils/notificationType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const notificationType = (eventType: string) => {
switch (eventType) {
case "FRIEND_REQUEST":
return "친구 요청 알림";
case "FRIEND_ACCEPT":
return "친구 요청 수락 알림";
case "SCHEDULE_REMINDER":
return "일정 알림";
case "GROUP_DELETE":
return "그룹 삭제 알림";
case "GROUP_INVITE":
return "그룹 초대 알림";
case "GROUP_ACCEPT":
return "그룹 초대 수락 알림";
case "GROUP_EXPEL":
return "그룹 강제 탈퇴 알림";
case "GROUP_SCHEDULE_DELETE":
return "그룹 일정 삭제 알림";
case "GROUP_SCHEDULE_CREATE":
return "그룹 일정 생성 알림";
case "COMMENT":
return "그룹 일정 댓글 알림";
case "BIRTHDAY":
return "생일 알림";
}
};
32 changes: 29 additions & 3 deletions src/routes/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,46 @@
import useAuthStore from "@store/useAuthStore";
import useBottomStore from "@store/useBottomStore";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import {
BOTTOM_INDEX_0,
BOTTOM_INDEX_1,
BOTTOM_INDEX_2,
BOTTOM_INDEX_3,
} from "../constants/routingUrl";
import { getSubscribeToSSE } from "@api/notification/getSubscribeToSSE";
import { useQueryClient } from "@tanstack/react-query";
import { useGetNotificationList } from "@api/notification/getNotificationList";

const ProtectedRoute = () => {
const { accessToken } = useAuthStore();

const intervalRef = useRef<number | null>(null);
const queryClient = useQueryClient();
const location = useLocation();
const { setBottomIndex } = useBottomStore();

const { data: notifications, isLoading, error } = useGetNotificationList(accessToken);

useEffect(() => {
if (!accessToken) {
return;
}
// 초기 SSE 구독 실행
getSubscribeToSSE(accessToken, queryClient);

// 1시간마다 SSE 자동 재연결
intervalRef.current = window.setInterval(() => {
console.log("🔄 SSE 자동 재연결 중...");
getSubscribeToSSE(accessToken, queryClient);
}, 60 * 60 * 1000);

return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
}
}, [accessToken])

useEffect(() => {
const currentPath = location.pathname;
if (currentPath.includes(BOTTOM_INDEX_0)) {
@@ -32,7 +58,7 @@ const ProtectedRoute = () => {
return <Navigate to="/" replace />;
}

return <Outlet />;
return <Outlet context={{notifications, isLoading, error}}/>;
};

export default ProtectedRoute;
Loading