Skip to content

Commit 3e00c54

Browse files
authored
Merge pull request #71 from ChatFlowProject/feat/api/SSE/FLOW-36
feat: SSE 알림 구현
2 parents 2be03d4 + 77b9028 commit 3e00c54

File tree

10 files changed

+292
-32
lines changed

10 files changed

+292
-32
lines changed

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@tanstack/react-query": "^5.69.0",
2323
"axios": "^1.7.9",
2424
"clsx": "^2.1.1",
25+
"event-source-polyfill": "^1.0.31",
2526
"firebase": "^11.5.0",
2627
"framer-motion": "^12.6.2",
2728
"idb": "^8.0.2",

src/app/Provider.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,24 @@ import { store } from './store';
44
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
55
import { SocketProvider } from '@service/feature/chat';
66
import { Toaster } from 'sonner';
7+
import { SSEProvider } from '@service/feature/chat/context/SSEProvider';
8+
// import { SSEProvider } from '@service/feature/chat/context/SSEProvider';
79

810
const queryClient = new QueryClient();
911

1012
const AppProviders = ({ children }: { children: ReactNode }) => {
1113
return (
1214
<ReduxProvider store={store}>
1315
<QueryClientProvider client={queryClient}>
14-
<SocketProvider>
16+
<SocketProvider>
17+
<SSEProvider>
1518
{children}
1619
<Toaster />
17-
</SocketProvider>
20+
</SSEProvider>
21+
</SocketProvider>
1822
</QueryClientProvider>
1923
</ReduxProvider>
2024
);
2125
};
2226

23-
export default AppProviders;
27+
export default AppProviders;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// 1. SSEProvider.tsx (Context + Provider)
2+
import { pushNotification } from '@service/feature/noti/hook/useSSE';
3+
import React, { createContext, useContext, useEffect, useState } from 'react';
4+
import { useDispatch } from 'react-redux';
5+
import { useSelector } from 'react-redux';
6+
import { toast } from 'sonner';
7+
import { RootState } from 'src/app/store';
8+
import { SSEMentionResponse, SSEResponse } from '../type/alert';
9+
import Alarm from '@components/common/Alarm';
10+
import { channel } from 'diagnostics_channel';
11+
12+
const SSEContext = createContext<{ events: MessageEvent[] }>({ events: [] });
13+
14+
export const SSEProvider = ({ children }: { children: React.ReactNode }) => {
15+
const [events, setEvents] = useState<MessageEvent[]>([]);
16+
const user = useSelector((state: RootState) => state.auth.user);
17+
const dispatch = useDispatch();
18+
19+
useEffect(() => {
20+
const eventSource = new EventSource(
21+
`http://flowchat.shop:30100/sse/subscribe?memberId=${user?.userId}`,
22+
);
23+
console.log('eventSource: ', eventSource);
24+
25+
eventSource.addEventListener('friendRequestNotification', (event) => {
26+
console.log('[friendRequestNotification] event: ', event);
27+
const data: SSEResponse = JSON.parse(event.data);
28+
console.log('[SSE] data: ,', data);
29+
30+
toast(
31+
<Alarm sender={data.sender} message={`친구 요청을 보냈습니다.`} />,
32+
{
33+
style: {
34+
padding: '12px',
35+
width: '220px',
36+
background: '#2e3036',
37+
borderRadius: '2px',
38+
boxShadow: '0px 0px 41px 0px rgba(0, 0, 0, 0.34)',
39+
border: '1px solid #42454A',
40+
},
41+
},
42+
);
43+
});
44+
45+
eventSource.addEventListener('friendAcceptNotification', (event) => {
46+
console.log('[friendAcceptNotification] event: ', event);
47+
const data: SSEResponse = JSON.parse(event.data);
48+
console.log('[SSE] data: ,', data);
49+
50+
toast(
51+
<Alarm sender={data.sender} message={`친구 요청을 승낙하였습니다.`} />,
52+
{
53+
style: {
54+
padding: '12px',
55+
width: '220px',
56+
background: '#2e3036',
57+
borderRadius: '2px',
58+
boxShadow: '0px 0px 41px 0px rgba(0, 0, 0, 0.34)',
59+
border: '1px solid #42454A',
60+
},
61+
},
62+
);
63+
});
64+
65+
eventSource.addEventListener('mention', (event) => {
66+
console.log('[mention] event: ', event);
67+
const data: SSEMentionResponse = JSON.parse(event.data);
68+
console.log('[SSE] data: ,', data);
69+
70+
toast(
71+
<Alarm
72+
sender={data.sender}
73+
message={`${data.content}`}
74+
channel={data.channel}
75+
team={data.team}
76+
category={data.category}
77+
chatId={data.chatId}
78+
/>,
79+
{
80+
style: {
81+
padding: '12px',
82+
width: '220px',
83+
background: '#2e3036',
84+
borderRadius: '2px',
85+
boxShadow: '0px 0px 41px 0px rgba(0, 0, 0, 0.34)',
86+
border: '1px solid #42454A',
87+
},
88+
},
89+
);
90+
});
91+
92+
eventSource.onmessage = (event) => {
93+
console.log('[SSE] 도착. event: ', event);
94+
// setEvents((prev) => [...prev, event]);
95+
const data = JSON.parse(event.data);
96+
console.log('[SSE] data: ,', data);
97+
};
98+
99+
eventSource.onerror = () => {
100+
console.log('[SSE] 에러 발생. SSE 종료.');
101+
eventSource.close();
102+
};
103+
104+
return () => {
105+
eventSource.close();
106+
};
107+
}, []);
108+
109+
return (
110+
<SSEContext.Provider value={{ events }}>{children}</SSEContext.Provider>
111+
);
112+
};
113+
114+
export const useSSE = () => useContext(SSEContext);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export interface SSEResponse {
2+
eventName: string;
3+
receiverId: string;
4+
sender: SSESender;
5+
}
6+
7+
export interface SSEMentionResponse extends SSEResponse {
8+
channel: SSEChannel;
9+
content: string;
10+
team: SSETeam;
11+
category: SSECategory;
12+
chatId: string;
13+
}
14+
15+
export interface SSESender {
16+
avatarUrl: string;
17+
id: string;
18+
name: string;
19+
}
20+
21+
export interface SSETeam {
22+
id: string;
23+
name: string;
24+
iconUrl: string;
25+
}
26+
27+
export interface SSEChannel {
28+
id: string;
29+
name: string;
30+
}
31+
32+
export interface SSESender {
33+
id: string;
34+
name: string;
35+
avatarUrl: string;
36+
}
37+
/**
38+
* TODO
39+
* 추후 백엔드 응답값 확인 후 변경
40+
*/
41+
export interface SSECategory {
42+
id: string;
43+
name: string;
44+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2+
import { SSESender } from '@service/feature/chat/type/alert';
3+
4+
// interface Notification {
5+
// id: string;
6+
// message: string;
7+
// type?: 'success' | 'error' | 'info';
8+
// }
9+
10+
interface Notification {
11+
id: string;
12+
sender: SSESender;
13+
eventName:
14+
| 'friendRequestNotification'
15+
| 'friendAcceptNotification'
16+
| 'mention';
17+
}
18+
interface NotificationState {
19+
queue: Notification[];
20+
}
21+
22+
const initialState: NotificationState = {
23+
queue: [],
24+
};
25+
26+
const notificationSlice = createSlice({
27+
name: 'notification',
28+
initialState,
29+
reducers: {
30+
pushNotification: (state, action: PayloadAction<Notification>) => {
31+
state.queue.push(action.payload);
32+
},
33+
shiftNotification: (state) => {
34+
state.queue.shift();
35+
},
36+
},
37+
});
38+
39+
export const { pushNotification, shiftNotification } =
40+
notificationSlice.actions;
41+
export default notificationSlice.reducer;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export interface Mention {
2+
sender: Sender;
3+
team: Team;
4+
channel: Channel;
5+
messageId: number;
6+
content: string;
7+
createdAt: string;
8+
}
9+
10+
interface Sender {
11+
id: string;
12+
name: string;
13+
avatarUrl: string;
14+
}
15+
16+
interface Team {
17+
id: string;
18+
name: string;
19+
iconUrl: string;
20+
}
21+
22+
interface Channel {
23+
id: number;
24+
name: string;
25+
}
Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,61 @@
1+
import {
2+
SSECategory,
3+
SSEChannel,
4+
SSESender,
5+
SSETeam,
6+
} from '@service/feature/chat/type/alert';
7+
import { useSelector } from 'react-redux';
8+
import { useNavigate } from 'react-router-dom';
9+
import { RootState } from 'src/app/store';
10+
111
export default function Alarm({
2-
img,
3-
name,
12+
sender,
13+
team,
414
channel,
515
category,
616
message,
17+
chatId,
718
}: {
8-
img: string
9-
name: string
10-
channel?: string
11-
category?: string
12-
message: string
19+
sender: SSESender;
20+
team?: SSETeam;
21+
channel?: SSEChannel;
22+
category?: SSECategory;
23+
message: string;
24+
chatId?: string;
1325
}) {
26+
const navigate = useNavigate();
27+
const user = useSelector((state: RootState) => state.auth.user);
28+
const handleClick = () => {
29+
if (team && channel) {
30+
navigate(`/channels/${team.id}/${chatId}`);
31+
} else {
32+
navigate('/channels/@me');
33+
}
34+
};
35+
1436
return (
15-
<div className='fixed bottom-2 right-2 flex gap-[8px] p-[12px] w-[220px] bg-[#2f3136] rounded-sm shadow-[0px_0px_41px_0px_rgba(0,0,0,0.34)]'>
16-
<div className='w-[30px] h-[30px]'>
17-
<img src={img} className='w-full' />
37+
<div
38+
onClick={handleClick}
39+
className='fixed bottom-2 right-2 flex gap-[8px] p-[12px] w-[220px] bg-[#2f3136] rounded-sm shadow-[0px_0px_41px_0px_rgba(0,0,0,0.34)]'
40+
>
41+
{/* <div className='flex gap-2' onClick={handleClick}> */}
42+
<div className='w-[40px] h-[40px]'>
43+
<img
44+
src={sender.avatarUrl || require('@assets/img/logo/chatflow.png')}
45+
className='w-full'
46+
/>
1847
</div>
1948
<div className='gap-[2px] flex flex-col justify-center'>
20-
<p className='text-white text-[10px] font-medium font-[Ginto]'>
21-
{name} {channel && `(#${channel}, ${category})`}
22-
</p>
23-
<p className='text-[#b9bbbe] text-[8px] font-medium font-[Whitney Semibold]'>
24-
{message}
49+
<p className='text-white text-[12px] font-medium font-[Ginto]'>
50+
{sender.name} {channel && `(#${channel.name}, ${category?.name})`}
2551
</p>
52+
<div className='text-[#b9bbbe] text-[10px] font-medium font-[Whitney Semibold] flex gap-1 items-center'>
53+
{channel && (
54+
<p className='text-[#818284] text-[9px]'>@{user?.nickname}</p>
55+
)}
56+
<p>{message}</p>
57+
</div>
2658
</div>
2759
</div>
28-
)
60+
);
2961
}

src/view/layout/LayoutWithSidebar.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import UserProfileBar from './profile/UserProfileBar.tsx';
44
import DirectChannelSidebar from './sidebar/channel/DirectChannelSidebar.tsx';
55
import ServerChannelSidebar from './sidebar/channel/ServerChannelSidebar.tsx';
66
import TopSidebar from '@components/layout/sidebar/top/TopSidebar.tsx';
7-
import TeamMemberSidebar from './sidebar/team/TeamMemberSidebar.tsx';
87

98
const LayoutWithSidebar = () => {
109
const location = useLocation();
@@ -23,16 +22,9 @@ const LayoutWithSidebar = () => {
2322
{isDMView ? <DirectChannelSidebar /> : <ServerChannelSidebar />}
2423
<UserProfileBar />
2524
</aside>
26-
<div className='flex flex-1 flex-row bg-wrapper text-white overflow-y-auto border border-[#42454A] rounded-[12px]'>
27-
<main className='flex-1 bg-wrapper text-white overflow-y-auto border border-[#42454A] rounded-[12px]'>
28-
<Outlet />
29-
</main>
30-
{!isDMView && (
31-
<aside>
32-
<TeamMemberSidebar />
33-
</aside>
34-
)}
35-
</div>
25+
<main className='flex-1 bg-wrapper text-white overflow-y-auto border border-[#42454A] rounded-[12px]'>
26+
<Outlet />
27+
</main>
3628
</div>
3729
</div>
3830
);

0 commit comments

Comments
 (0)