Skip to content

Commit c76909f

Browse files
authored
feat: Browse Page 채널 목록 구현(Infinite Scroll 구현, 채널 Join 기능 구현) (#185)
* fix: 채널 목록 조회 API 오류 해결 - x-total-count header를 사용할 수 있도록 수정 - 오타 수정 * feat: 채널 개수와 join 여부 연동 - channel-saga, api에 channelCount 추가 * feat: getNextChannels API 추가 - offsetTitle을 기준으로 다음 채널목록을 가져오는 API 추가 * feat: Channel Store에 loadNextChannels 추가 - 다음 채널 목록을 가져오는 loadNextChannels type, action, reducer, saga 구현 * feat: 채널 목록 infinite scroll 구현 * feat: 채팅방 JOIN 기능 구현 - joinChannel API 추가 - Channel Store에 joinChannel types, action, reducer, saga 구현 - 채팅방 목록에서 Join 버튼 클릭 시 채팅방 들어가기 동작 구현 - join chatroom socket 리팩토링
1 parent 837fe8b commit c76909f

File tree

17 files changed

+153
-38
lines changed

17 files changed

+153
-38
lines changed

client/.eslintrc.json

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
}
4141
],
4242
"linebreak-style": 0,
43+
"no-case-declarations": 0,
4344
"no-use-before-define": "off",
4445
"import/prefer-default-export": "off",
4546
"import/no-unresolved": "off",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { JOIN_CHATROOM, joinChatroomState } from '@socket/types/chatroom-types';
2+
import socket from '../socketIO';
3+
4+
export const joinChatroom = (chatroomId: joinChatroomState) => {
5+
socket.emit(JOIN_CHATROOM, { chatroomId });
6+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const JOIN_CHATROOM = 'join chatroom';
2+
3+
export interface joinChatroomState {
4+
chatroomId: number;
5+
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
import { INIT_CHANNELS_ASYNC } from '../types/channel-types';
1+
import { INIT_CHANNELS_ASYNC, LOAD_NEXT_CHANNELS_ASYNC, JOIN_CHANNEL_ASYNC } from '../types/channel-types';
22

33
export const initChannels = () => ({ type: INIT_CHANNELS_ASYNC });
4+
export const loadNextChannels = (payload: any) => ({ type: LOAD_NEXT_CHANNELS_ASYNC, payload });
5+
export const joinChannel = (payload: any) => ({ type: JOIN_CHANNEL_ASYNC, payload });

client/src/common/store/reducers/channel-reducer.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { channelsState, ChannelTypes, INIT_CHANNELS } from '../types/channel-types';
1+
import { channelState, channelsState, ChannelTypes, INIT_CHANNELS, LOAD_NEXT_CHANNELS, JOIN_CHANNEL } from '../types/channel-types';
22

33
const initialState: channelsState = {
44
channelCount: 0,
@@ -12,6 +12,18 @@ export default function channelReducer(state = initialState, action: ChannelType
1212
channelCount: action.payload.channelCount,
1313
channels: action.payload.channels
1414
};
15+
case LOAD_NEXT_CHANNELS:
16+
return {
17+
...state,
18+
channels: [...state.channels, ...action.payload.channels]
19+
};
20+
case JOIN_CHANNEL:
21+
const { chatroomId } = action.payload;
22+
const channels = state.channels.map((channel: channelState) => {
23+
if (channel.chatroomId === chatroomId) return { ...channel, isJoined: true };
24+
return channel;
25+
});
26+
return { ...state, channels };
1527
default:
1628
return state;
1729
}
+38-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,52 @@
11
import { call, put, takeEvery } from 'redux-saga/effects';
22
import API from '@utils/api';
3-
import { INIT_CHANNELS, INIT_CHANNELS_ASYNC } from '../types/channel-types';
3+
import { joinChatroom } from '@socket/emits/chatroom';
4+
import {
5+
INIT_CHANNELS,
6+
INIT_CHANNELS_ASYNC,
7+
JOIN_CHANNEL,
8+
JOIN_CHANNEL_ASYNC,
9+
LOAD_NEXT_CHANNELS,
10+
LOAD_NEXT_CHANNELS_ASYNC
11+
} from '../types/channel-types';
12+
import { ADD_CHANNEL } from '../types/chatroom-types';
413

514
function* initChannelsSaga() {
615
try {
7-
const channelCount = 0;
8-
const channels = yield call(API.getChannels);
16+
const { channels, channelCount } = yield call(API.getChannels);
917
yield put({ type: INIT_CHANNELS, payload: { channelCount, channels } });
1018
} catch (e) {
1119
console.log(e);
1220
}
1321
}
1422

23+
function* loadNextChannels(action: any) {
24+
try {
25+
const { title } = action.payload;
26+
const nextChannels = yield call(API.getNextChannels, title);
27+
yield put({ type: LOAD_NEXT_CHANNELS, payload: { channels: nextChannels } });
28+
} catch (e) {
29+
console.log(e);
30+
}
31+
}
32+
33+
function* joinChannel(action: any) {
34+
try {
35+
const { chatroomId } = action.payload;
36+
yield call(API.joinChannel, chatroomId);
37+
yield put({ type: JOIN_CHANNEL, payload: { chatroomId } });
38+
const chatroom = yield call(API.getChatroom, chatroomId);
39+
const { chatType, isPrivate, title } = chatroom;
40+
const payload = { chatroomId, chatType, isPrivate, title };
41+
joinChatroom(chatroomId);
42+
yield put({ type: ADD_CHANNEL, payload });
43+
} catch (e) {
44+
console.log(e);
45+
}
46+
}
47+
1548
export function* channelSaga() {
1649
yield takeEvery(INIT_CHANNELS_ASYNC, initChannelsSaga);
50+
yield takeEvery(LOAD_NEXT_CHANNELS_ASYNC, loadNextChannels);
51+
yield takeEvery(JOIN_CHANNEL_ASYNC, joinChannel);
1752
}

client/src/common/store/sagas/chatroom-saga.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { call, put, takeEvery } from 'redux-saga/effects';
22
import API from '@utils/api';
3-
import socket from '@socket/socketIO';
3+
import { joinChatroom } from '@socket/emits/chatroom';
44
import {
55
LOAD,
66
LOAD_ASYNC,
@@ -54,7 +54,7 @@ function* addChannel(action: any) {
5454
try {
5555
const chatroomId = yield call(API.createChannel, action.payload.title, action.payload.description, action.payload.isPrivate);
5656
const payload = { chatroomId, chatType: 'Channel', isPrivate: action.payload.isPrivate, title: action.payload.title };
57-
socket.emit('join chatroom', { chatroomId });
57+
joinChatroom(chatroomId);
5858
yield put({ type: ADD_CHANNEL, payload });
5959
} catch (e) {
6060
alert('같은 이름의 채널이 존재합니다.');
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
export const INIT_CHANNELS = 'INIT_CHANNELS';
22
export const INIT_CHANNELS_ASYNC = 'INIT_CHANNELS_ASYNC';
3+
export const LOAD_NEXT_CHANNELS = 'LOAD_NEXT_CHANNELS';
4+
export const LOAD_NEXT_CHANNELS_ASYNC = 'LOAD_NEXT_CHANNELS_ASYNC';
5+
export const JOIN_CHANNEL = 'JOIN_CHANNEL';
6+
export const JOIN_CHANNEL_ASYNC = 'JOIN_CHANNEL_ASYNC';
37

48
export interface channelState {
5-
channelId: number;
9+
chatroomId: number;
610
title: string;
7-
description: string;
11+
description?: string;
812
isPrivate: boolean;
913
members: number;
1014
isJoined: boolean;
@@ -15,9 +19,23 @@ export interface channelsState {
1519
channels: Array<channelState>;
1620
}
1721

22+
export interface joinChannelState {
23+
chatroomId: number;
24+
}
25+
1826
interface InitChannelsAction {
1927
type: typeof INIT_CHANNELS;
2028
payload: channelsState;
2129
}
2230

23-
export type ChannelTypes = InitChannelsAction;
31+
interface LoadNextChannels {
32+
type: typeof LOAD_NEXT_CHANNELS;
33+
payload: channelsState;
34+
}
35+
36+
interface JoinChannel {
37+
type: typeof JOIN_CHANNEL;
38+
payload: joinChannelState;
39+
}
40+
41+
export type ChannelTypes = InitChannelsAction | LoadNextChannels | JoinChannel;

client/src/common/utils/api.ts

+11
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ export default {
7171

7272
getChannels: async () => {
7373
const response = await axios.get(`api/chatrooms`);
74+
const channelCount = response.headers['x-total-count'];
75+
return { channels: response.data, channelCount };
76+
},
77+
78+
getNextChannels: async (title: string) => {
79+
const response = await axios.get(`api/chatrooms?offsetTitle=${title}`);
80+
return response.data;
81+
},
82+
83+
joinChannel: async (chatroomId: number) => {
84+
const response = await axios.post(`api/user-chatrooms`, { chatroomId });
7485
return response.data;
7586
}
7687
};

client/src/components/atoms/Button/Button.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ interface ButtonProps {
99
fontColor: string;
1010
isBold?: boolean;
1111
hoverColor?: string;
12+
width?: string;
13+
height?: string;
1214
onClick?: () => void;
1315
}
1416

1517
const StyledButton = styled.button<any>`
1618
display: flex;
1719
align-items: center;
20+
justify-content: center;
1821
background-color: ${(props) => props.backgroundColor};
1922
border: 2px solid ${(props) => props.borderColor};
2023
color: ${(props) => props.fontColor};
@@ -24,6 +27,8 @@ const StyledButton = styled.button<any>`
2427
cursor: pointer;
2528
font-weight: ${(props) => (props.isBold ? 'bold' : null)};
2629
${(props) => (props.hoverColor ? `&:hover { background-color: ${color.hover_primary}}` : '')}
30+
${(props) => (props.width ? `width: ${props.width}}` : '')}
31+
${(props) => (props.height ? `height: ${props.height}}` : '')}
2732
`;
2833

2934
const Button: React.FC<ButtonProps> = ({ children, backgroundColor, borderColor, fontColor, isBold, hoverColor, ...props }) => {

client/src/components/molecules/BrowsePageChannelButton/BrowsePageChannelButton.tsx

+8-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ const BrowsePageChannelButton: React.FC<BrowsePageChannelButtonProps> = ({ isJoi
1212
return (
1313
<>
1414
{isJoined ? (
15-
<Button onClick={handlingLeaveButton} backgroundColor={color.tertiary} borderColor={color.secondary} fontColor={color.primary} {...props}>
15+
<Button
16+
onClick={handlingLeaveButton}
17+
backgroundColor={color.tertiary}
18+
borderColor={color.secondary}
19+
fontColor={color.primary}
20+
width="5rem"
21+
{...props}>
1622
Leave
1723
</Button>
1824
) : (
@@ -21,6 +27,7 @@ const BrowsePageChannelButton: React.FC<BrowsePageChannelButtonProps> = ({ isJoi
2127
backgroundColor={color.button_secondary}
2228
borderColor={color.button_secondary}
2329
fontColor={color.text_secondary}
30+
width="5rem"
2431
{...props}>
2532
Join
2633
</Button>

client/src/components/organisms/BrowsePageChannel/BrowsePageChannel.stories.tsx

+2-6
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,12 @@ export default {
99

1010
const Template: Story<BrowsePageChannelProps> = (args) => <BrowsePageChannel {...args} />;
1111

12-
const handlingJoinButton = () => {};
13-
const handlingLeaveButton = () => {};
14-
1512
export const BlackBrowsePageChannel = Template.bind({});
1613
BlackBrowsePageChannel.args = {
14+
chatroomId: 1,
1715
title: 'notice',
1816
isJoined: true,
1917
members: 4,
2018
description: '공지사항을 안내하는 채널',
21-
isPrivate: true,
22-
handlingJoinButton,
23-
handlingLeaveButton
19+
isPrivate: true
2420
};

client/src/components/organisms/BrowsePageChannel/BrowsePageChannel.tsx

+9-12
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ import { color } from '@theme/index';
44
import { BrowsePageChannelHeader } from '@components/molecules/BrowsePageChannelHeader/BrowsePageChannelHeader';
55
import { BrowsePageChannelBody } from '@components/molecules/BrowsePageChannelBody/BrowsePageChannelBody';
66
import { BrowsePageChannelButton } from '@components/molecules/BrowsePageChannelButton/BrowsePageChannelButton';
7+
import { useDispatch } from 'react-redux';
8+
import { joinChannel } from '@store/actions/channel-action';
79

810
interface BrowsePageChannelProps {
11+
chatroomId: number;
912
title: string;
1013
description?: string;
1114
isPrivate?: boolean;
1215
members: number;
1316
isJoined?: boolean;
14-
handlingJoinButton?: () => void;
15-
handlingLeaveButton?: () => void;
1617
}
1718

1819
const BrowsePageChannelContainer = styled.div<any>`
@@ -43,16 +44,12 @@ const ButtonWrap = styled.div<any>`
4344
}
4445
`;
4546

46-
const BrowsePageChannel: React.FC<BrowsePageChannelProps> = ({
47-
title,
48-
isJoined,
49-
members,
50-
description,
51-
isPrivate,
52-
handlingJoinButton,
53-
handlingLeaveButton,
54-
...props
55-
}) => {
47+
const BrowsePageChannel: React.FC<BrowsePageChannelProps> = ({ chatroomId, title, isJoined, members, description, isPrivate, ...props }) => {
48+
const dispatch = useDispatch();
49+
const handlingJoinButton = () => {
50+
dispatch(joinChannel({ chatroomId }));
51+
};
52+
const handlingLeaveButton = () => {};
5653
return (
5754
<BrowsePageChannelContainer {...props}>
5855
<BrowsePageChannelContent>

client/src/components/organisms/BrowsePageChannelList/BrowsePageChannelList.stories.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,22 @@ export const BlackBrowsePageChannelList = Template.bind({});
1313
BlackBrowsePageChannelList.args = {
1414
channels: [
1515
{
16-
channelId: 1,
16+
chatroomId: 1,
1717
title: 'notice',
1818
description: '공지사항을 안내하는 채널',
1919
isPrivate: false,
2020
members: 110,
2121
isJoined: true
2222
},
2323
{
24-
channelId: 2,
24+
chatroomId: 2,
2525
title: '질의응답',
2626
isPrivate: false,
2727
members: 10,
2828
isJoined: false
2929
},
3030
{
31-
channelId: 3,
31+
chatroomId: 3,
3232
title: 'black',
3333
description: 'black',
3434
isPrivate: true,

client/src/components/organisms/BrowsePageChannelList/BrowsePageChannelList.tsx

+22-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import styled from 'styled-components';
33
import { BrowsePageChannel } from '@components/organisms';
4+
import { useDispatch } from 'react-redux';
5+
import { channelState } from '@store/types/channel-types';
6+
import { loadNextChannels } from '@store/actions/channel-action';
47

58
interface BrowsePageChannelListProps {
6-
channels: Array<object>;
9+
channels: Array<channelState>;
710
}
811

912
const BrowsePageChannelListContainter = styled.div<any>`
@@ -15,10 +18,22 @@ const BrowsePageChannelListContainter = styled.div<any>`
1518
`;
1619

1720
const BrowsePageChannelList: React.FC<BrowsePageChannelListProps> = ({ channels, ...props }) => {
21+
const dispatch = useDispatch();
22+
const [lastRequestChannelTitle, setLastRequestChannelTitle] = useState('');
23+
const onScrollHandler = (e: any) => {
24+
const title: string | null = channels[channels.length - 1]?.title;
25+
if (e.target.scrollTop >= e.target.scrollHeight / 2) {
26+
if (title === lastRequestChannelTitle) return;
27+
dispatch(loadNextChannels({ title }));
28+
setLastRequestChannelTitle(title);
29+
}
30+
};
31+
1832
const createMessages = () => {
1933
return channels.map((channel: any) => (
2034
<BrowsePageChannel
2135
key={channel.chatroomId}
36+
chatroomId={channel.chatroomId}
2237
title={channel.title}
2338
description={channel.description}
2439
isPrivate={channel.isPrivate}
@@ -28,7 +43,11 @@ const BrowsePageChannelList: React.FC<BrowsePageChannelListProps> = ({ channels,
2843
));
2944
};
3045

31-
return <BrowsePageChannelListContainter {...props}>{createMessages()}</BrowsePageChannelListContainter>;
46+
return (
47+
<BrowsePageChannelListContainter onScroll={onScrollHandler} {...props}>
48+
{createMessages()}
49+
</BrowsePageChannelListContainter>
50+
);
3251
};
3352

3453
export { BrowsePageChannelList, BrowsePageChannelListProps };

server/src/controller/chatroom-controller.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ const ChatroomController = {
2929
const { offsetTitle } = req.query;
3030
const chatrooms = await ChatroomService.getInstance().getChatrooms(Number(userId), String(offsetTitle));
3131
const chatroomCount = await ChatroomService.getInstance().getChatroomCount(userId);
32-
res.setHeader('X-total-count', chatroomCount);
32+
res.header('Access-Control-Expose-Headers', 'x-total-count');
33+
res.setHeader('x-total-count', chatroomCount);
3334
res.status(HttpStatusCode.OK).json(chatrooms);
3435
} catch (err) {
3536
next(err);

server/src/service/chatroom-service.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ class ChatroomService {
143143
return chatrooms.map((chatroom, idx) => {
144144
const { chatroomId, title, description, isPrivate, userChatrooms } = chatroom;
145145
const members = userChatrooms.length;
146-
const isJoinend = isJoinedArr[idx];
147-
return { chatroomId, title, description, isPrivate, members, isJoinend };
146+
const isJoined = isJoinedArr[idx];
147+
return { chatroomId, title, description, isPrivate, members, isJoined };
148148
});
149149
}
150150

0 commit comments

Comments
 (0)