diff --git a/package-lock.json b/package-lock.json index d5539d5..639c8e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@tanstack/react-query": "^5.69.0", "axios": "^1.7.9", "clsx": "^2.1.1", + "event-source-polyfill": "^1.0.31", "firebase": "^11.5.0", "framer-motion": "^12.6.2", "idb": "^8.0.2", @@ -10852,6 +10853,12 @@ "node": ">= 0.6" } }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==", + "license": "MIT" + }, "node_modules/eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", diff --git a/package.json b/package.json index c90fa34..ce47fa4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@tanstack/react-query": "^5.69.0", "axios": "^1.7.9", "clsx": "^2.1.1", + "event-source-polyfill": "^1.0.31", "firebase": "^11.5.0", "framer-motion": "^12.6.2", "idb": "^8.0.2", diff --git a/src/app/Provider.tsx b/src/app/Provider.tsx index 4ad5974..ae1c81c 100644 --- a/src/app/Provider.tsx +++ b/src/app/Provider.tsx @@ -4,6 +4,8 @@ import { store } from './store'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SocketProvider } from '@service/feature/chat'; import { Toaster } from 'sonner'; +import { SSEProvider } from '@service/feature/chat/context/SSEProvider'; +// import { SSEProvider } from '@service/feature/chat/context/SSEProvider'; const queryClient = new QueryClient(); @@ -11,13 +13,15 @@ const AppProviders = ({ children }: { children: ReactNode }) => { return ( - + + {children} - + + ); }; -export default AppProviders; \ No newline at end of file +export default AppProviders; diff --git a/src/service/feature/chat/context/SSEProvider.tsx b/src/service/feature/chat/context/SSEProvider.tsx new file mode 100644 index 0000000..2971450 --- /dev/null +++ b/src/service/feature/chat/context/SSEProvider.tsx @@ -0,0 +1,114 @@ +// 1. SSEProvider.tsx (Context + Provider) +import { pushNotification } from '@service/feature/noti/hook/useSSE'; +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { toast } from 'sonner'; +import { RootState } from 'src/app/store'; +import { SSEMentionResponse, SSEResponse } from '../type/alert'; +import Alarm from '@components/common/Alarm'; +import { channel } from 'diagnostics_channel'; + +const SSEContext = createContext<{ events: MessageEvent[] }>({ events: [] }); + +export const SSEProvider = ({ children }: { children: React.ReactNode }) => { + const [events, setEvents] = useState([]); + const user = useSelector((state: RootState) => state.auth.user); + const dispatch = useDispatch(); + + useEffect(() => { + const eventSource = new EventSource( + `http://flowchat.shop:30100/sse/subscribe?memberId=${user?.userId}`, + ); + console.log('eventSource: ', eventSource); + + eventSource.addEventListener('friendRequestNotification', (event) => { + console.log('[friendRequestNotification] event: ', event); + const data: SSEResponse = JSON.parse(event.data); + console.log('[SSE] data: ,', data); + + toast( + , + { + style: { + padding: '12px', + width: '220px', + background: '#2e3036', + borderRadius: '2px', + boxShadow: '0px 0px 41px 0px rgba(0, 0, 0, 0.34)', + border: '1px solid #42454A', + }, + }, + ); + }); + + eventSource.addEventListener('friendAcceptNotification', (event) => { + console.log('[friendAcceptNotification] event: ', event); + const data: SSEResponse = JSON.parse(event.data); + console.log('[SSE] data: ,', data); + + toast( + , + { + style: { + padding: '12px', + width: '220px', + background: '#2e3036', + borderRadius: '2px', + boxShadow: '0px 0px 41px 0px rgba(0, 0, 0, 0.34)', + border: '1px solid #42454A', + }, + }, + ); + }); + + eventSource.addEventListener('mention', (event) => { + console.log('[mention] event: ', event); + const data: SSEMentionResponse = JSON.parse(event.data); + console.log('[SSE] data: ,', data); + + toast( + , + { + style: { + padding: '12px', + width: '220px', + background: '#2e3036', + borderRadius: '2px', + boxShadow: '0px 0px 41px 0px rgba(0, 0, 0, 0.34)', + border: '1px solid #42454A', + }, + }, + ); + }); + + eventSource.onmessage = (event) => { + console.log('[SSE] 도착. event: ', event); + // setEvents((prev) => [...prev, event]); + const data = JSON.parse(event.data); + console.log('[SSE] data: ,', data); + }; + + eventSource.onerror = () => { + console.log('[SSE] 에러 발생. SSE 종료.'); + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, []); + + return ( + {children} + ); +}; + +export const useSSE = () => useContext(SSEContext); diff --git a/src/service/feature/chat/type/alert.ts b/src/service/feature/chat/type/alert.ts new file mode 100644 index 0000000..21b6739 --- /dev/null +++ b/src/service/feature/chat/type/alert.ts @@ -0,0 +1,44 @@ +export interface SSEResponse { + eventName: string; + receiverId: string; + sender: SSESender; +} + +export interface SSEMentionResponse extends SSEResponse { + channel: SSEChannel; + content: string; + team: SSETeam; + category: SSECategory; + chatId: string; +} + +export interface SSESender { + avatarUrl: string; + id: string; + name: string; +} + +export interface SSETeam { + id: string; + name: string; + iconUrl: string; +} + +export interface SSEChannel { + id: string; + name: string; +} + +export interface SSESender { + id: string; + name: string; + avatarUrl: string; +} +/** + * TODO + * 추후 백엔드 응답값 확인 후 변경 + */ +export interface SSECategory { + id: string; + name: string; +} diff --git a/src/service/feature/noti/hook/useSSE.ts b/src/service/feature/noti/hook/useSSE.ts new file mode 100644 index 0000000..f2a8106 --- /dev/null +++ b/src/service/feature/noti/hook/useSSE.ts @@ -0,0 +1,41 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { SSESender } from '@service/feature/chat/type/alert'; + +// interface Notification { +// id: string; +// message: string; +// type?: 'success' | 'error' | 'info'; +// } + +interface Notification { + id: string; + sender: SSESender; + eventName: + | 'friendRequestNotification' + | 'friendAcceptNotification' + | 'mention'; +} +interface NotificationState { + queue: Notification[]; +} + +const initialState: NotificationState = { + queue: [], +}; + +const notificationSlice = createSlice({ + name: 'notification', + initialState, + reducers: { + pushNotification: (state, action: PayloadAction) => { + state.queue.push(action.payload); + }, + shiftNotification: (state) => { + state.queue.shift(); + }, + }, +}); + +export const { pushNotification, shiftNotification } = + notificationSlice.actions; +export default notificationSlice.reducer; diff --git a/src/service/feature/noti/types/noti.ts b/src/service/feature/noti/types/noti.ts new file mode 100644 index 0000000..f11499e --- /dev/null +++ b/src/service/feature/noti/types/noti.ts @@ -0,0 +1,25 @@ +export interface Mention { + sender: Sender; + team: Team; + channel: Channel; + messageId: number; + content: string; + createdAt: string; +} + +interface Sender { + id: string; + name: string; + avatarUrl: string; +} + +interface Team { + id: string; + name: string; + iconUrl: string; +} + +interface Channel { + id: number; + name: string; +} diff --git a/src/view/components/common/Alarm.tsx b/src/view/components/common/Alarm.tsx index 3c8108a..11a3859 100644 --- a/src/view/components/common/Alarm.tsx +++ b/src/view/components/common/Alarm.tsx @@ -1,29 +1,61 @@ +import { + SSECategory, + SSEChannel, + SSESender, + SSETeam, +} from '@service/feature/chat/type/alert'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { RootState } from 'src/app/store'; + export default function Alarm({ - img, - name, + sender, + team, channel, category, message, + chatId, }: { - img: string - name: string - channel?: string - category?: string - message: string + sender: SSESender; + team?: SSETeam; + channel?: SSEChannel; + category?: SSECategory; + message: string; + chatId?: string; }) { + const navigate = useNavigate(); + const user = useSelector((state: RootState) => state.auth.user); + const handleClick = () => { + if (team && channel) { + navigate(`/channels/${team.id}/${chatId}`); + } else { + navigate('/channels/@me'); + } + }; + return ( -
-
- +
+ {/*
*/} +
+
-

- {name} {channel && `(#${channel}, ${category})`} -

-

- {message} +

+ {sender.name} {channel && `(#${channel.name}, ${category?.name})`}

+
+ {channel && ( +

@{user?.nickname}

+ )} +

{message}

+
- ) + ); } diff --git a/src/view/layout/LayoutWithSidebar.tsx b/src/view/layout/LayoutWithSidebar.tsx index f6e64fb..367da0c 100644 --- a/src/view/layout/LayoutWithSidebar.tsx +++ b/src/view/layout/LayoutWithSidebar.tsx @@ -4,7 +4,6 @@ import UserProfileBar from './profile/UserProfileBar.tsx'; import DirectChannelSidebar from './sidebar/channel/DirectChannelSidebar.tsx'; import ServerChannelSidebar from './sidebar/channel/ServerChannelSidebar.tsx'; import TopSidebar from '@components/layout/sidebar/top/TopSidebar.tsx'; -import TeamMemberSidebar from './sidebar/team/TeamMemberSidebar.tsx'; const LayoutWithSidebar = () => { const location = useLocation(); @@ -23,16 +22,9 @@ const LayoutWithSidebar = () => { {isDMView ? : } -
-
- -
- {!isDMView && ( - - )} -
+
+ +
); diff --git a/src/view/layout/sidebar/components/channel/DirectMessages.tsx b/src/view/layout/sidebar/components/channel/DirectMessages.tsx index f21d330..5dfb343 100644 --- a/src/view/layout/sidebar/components/channel/DirectMessages.tsx +++ b/src/view/layout/sidebar/components/channel/DirectMessages.tsx @@ -14,9 +14,9 @@ const DirectMessages = () => { return (
- {data?.map((item) => ( + {data?.map((item, index) => (