메시지 전송, 리스트 읽기 과정에서 sender, receiver에 혼동이 있을 수 있으므로 정리를 하고 모듈을 작성하도록 하겠습니다.
메시지는 sender, receiver를 기준으로 userList, chatList를 받아오도록 합니다. 여기서 sender는 송신자, receiver는 수신자인데 케이스를 살펴 보도록 하겠습니다.
채팅창으로 가기 위한 경로는 총 3가지입니다.
1) 게시글의 메시지 버튼
1-1) sender: 로그인 유저, receiver: 게시글의 writer
1-2) userList(sender), chatList(sender, receiver), send(sender, receiver)
2) 유저리스트의 item클릭
2-1) sender: 로그인 유저, receiver: 유저 리스트 item의 sender
2-2) userList(sender), chatList(sender, receiver), send(sender, receiver)
3) 마이페이지의 메시지함 버튼
2) ~
message-service는 이와 같은 흐름을 가지게 되므로 3가지 케이스를 바탕으로 모듈을 작성하도록 하겠습니다.
앞서 message-service를 수정하고 관련 UI를 만들어보았습니다. 그러면 이 프론트엔드와 백엔드를 연결해주기 위한 리덕스 모듈이 필요한데, 이 모듈을 만들어 보도록 하겠습니다.
우선 endpoint연결을 위한 chat파일을 만들도록 하겠습니다.
다음의 패키지를 설치하겠습니다.
npm install qs
qs라이브러리는 query string을 위한 라이브러리입니다. query string은 api 요청시 ?a=:a & b=:b 와 같은 파라미터입니다. 이 라이브러리를 이용하여 api endpoint를 만들도록 하겠습니다.
import client from './client';
export const send = ({
sender,
receiver,
content
}) => client.post("/message-service/send", {
sender,
receiver,
content
});
export const getUserList = receiver => client.get("/message-service/user-list", receiver);
export const getMessageList = ({
sender,
receiver
}) => client.get("/message-service/message-list", {
sender,
receiver
});
import { createAction, handleActions } from "redux-actions";
import createRequestSaga, {
createRequestActionTypes
} from "../lib/createRequestSaga";
import * as chatAPI from '../lib/api/chat';
import { takeLatest } from "redux-saga/effects";
const INITIALIZE = 'send/INITIALIZE';
const CHANGE_FIELD = 'send/CHANGE_FIELD';
const [
SEND_CHAT,
SEND_CHAT_SUCCESS,
SEND_CHAT_FAILURE,
] = createRequestActionTypes('send/SEND_CHAT');
export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
key,
value
}));
export const sendChat = createAction(SEND_CHAT, ({
sender,
receiver,
content
}) => ({
sender,
receiver,
content
}));
const sendChatSaga = createRequestSaga(SEND_CHAT, chatAPI.send);
export function* sendSaga() {
yield takeLatest(SEND_CHAT, sendChatSaga);
}
const initialState = {
sender: '',
receiver: '',
content: '',
chat: null,
chatError: null
};
const send = handleActions(
{
[INITIALIZE]: state => initialState,
[CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
...state,
[key]: value,
}),
[SEND_CHAT]: state => ({
...state,
chat: null,
chatError: null,
}),
[SEND_CHAT_SUCCESS]: (state, { payload: chat }) => ({
...state,
chat,
}),
[SEND_CHAT_FAILURE]: (state, { payload: chatError }) => ({
...state,
chatError,
}),
},
initialState,
);
export default send;
채팅을 보내기 위한 모듈입니다. sender, receiver, content를 담은 액션을 앞서 작성한 api endpoint와 매칭시켜 POST 요청을 하도록 도와주는 모듈입니다.
이어서 messageList 모듈도 만들도록 하겠습니다.
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/createRequestSaga';
import * as chatAPI from '../lib/api/chat';
import { takeLatest } from 'redux-saga/effects';
const CHANGE_FIELD = 'messages/CHANGE_FIELD';
const [
READ_CHATLIST,
READ_CHATLIST_SUCCESS,
READ_CHATLIST_FAILURE,
] = createRequestActionTypes('messages/READ_CHATLIST');
const [
READ_USERLIST,
READ_USERLIST_SUCCESS,
READ_USERLIST_FAILURE,
] = createRequestActionTypes('messages/READ_USERLIST');
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
key,
value
}));
export const readChatList = createAction(READ_CHATLIST, ({
receiver,
sender
}) => ({
receiver,
sender,
}));
export const readUserList = createAction(READ_USERLIST, sender => sender);
const readChatListSaga = createRequestSaga(READ_CHATLIST, chatAPI.getMessageList);
const readUserListSaga = createRequestSaga(READ_USERLIST, chatAPI.getUserList);
export function* messageListSaga() {
yield takeLatest(READ_CHATLIST, readChatListSaga);
yield takeLatest(READ_USERLIST, readUserListSaga);
}
const initialState = {
userList: null,
chatList: null,
error: null,
};
const messageList = handleActions(
{
[CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
...state,
[key]: value,
}),
[READ_CHATLIST_SUCCESS]: (state, { payload: chatList }) => ({
...state,
chatList,
}),
[READ_CHATLIST_FAILURE]: (state, { payload: error }) => ({
...state,
error
}),
[READ_USERLIST_SUCCESS]: (state, { payload: userList }) => ({
...state,
userList,
}),
[READ_USERLIST_FAILURE]: (state, { payload: error }) => ({
...state,
error,
}),
},
initialState,
);
export default messageList;
messageList의 주요 state는 userList, chatList입니다. userList는 좌측 컴포넌트에 채팅룸을 만들어주기 위한 리스트이며, chatList는 채팅메시지들을 담은 리스트입니다.
모듈들을 만들었으니 리듀서에 모듈들을 담아주도록 하겠습니다.
import { combineReducers } from "redux";
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import user, { userSaga } from "./user";
import write, { writeSaga } from "./write";
import post, { postSaga } from "./post";
import postList, { postListSaga } from "./postList";
import writeComment, { writeCommentSaga } from "./writeComment";
import send, { sendSaga } from './send';
import messageList, { messageListSaga } from "./messageList";
import loading from './loading';
const rootReducer = combineReducers(
{
loading,
auth,
user,
write,
post,
postList,
writeComment,
send,
messageList,
},
);
export function* rootSaga() {
yield all([
authSaga(),
userSaga(),
writeSaga(),
postSaga(),
postListSaga(),
writeCommentSaga(),
sendSaga(),
messageListSaga(),
]);
}
export default rootReducer;
리듀서에 담았으니 컴포넌트에서 이 모듈들을 호출하여 연동을 하도록 하겠습니다.
우선 포스트에서 메시지 아이콘 클릭 시, 채팅창으로 넘어가는 경우를 먼저 작성하도록 하겠습니다.
...
return(
<PostViewerBlock>
...
<MessageArea>
<StyledShorcut path={ `/messages/${post.writer}` }
src={ paper_plane_outline }
/>
</MessageArea>
...
</PostViewerBlock>
);
};
export default PostViewer;
writer는 해당 글의 작성자입니다. 즉, 메시지 아이콘을 클릭하게 되면 receiver는 writer가 되며 sender는 현재 로그인한 유저가 자리하게 됩니다.
import React, { useEffect } from 'react';
import styled from 'styled-components';
import InputBar from './common/InputBar';
import InputButton from './common/InpuButton';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, sendChat } from '../../modules/send';
import { readChatList } from '../../modules/messageList';
import { withRouter } from 'react-router-dom';
...
const SendForm = ({ match }) => {
const dispatch = useDispatch();
const {
nickname,
sender,
receiver,
content,
} = useSelector(({
send,
user
}) => ({
nickname: user.user.nickname,
sender: send.sender,
receiver: send.receiver,
content: send.content
}));
const onChange = e => {
e.preventDefault();
const { value, name } = e.target;
dispatch(changeField({
key: name,
value
}));
};
const onSend = e => {
e.preventDefault();
dispatch(sendChat({
receiver,
sender,
content,
}));
dispatch(readChatList({
sender,
receiver,
}));
};
// 메시지 아이콘 클릭 후 sender = writer로 변경
useEffect(() => {
dispatch(changeField({
key: 'sender',
value: nickname
}));
}, [dispatch, nickname]);
useEffect(() => {
dispatch(changeField({
key: 'receiver',
value: match.params.writer
}))
}, [dispatch, match]);
return (
<SendContainer>
<StyledInputBar name="content"
placeholder="메시지를 입력해주세요"
onChange={ onChange } />
<InputButton text="보내기"
onClick={ onSend } />
</SendContainer>
);
};
export default withRouter(SendForm);
onSend 함수에 readChatList함수도 같이 넣어주었습니다. 그 이유는 메시지를 보내면 실시간으로 보낸 메시지도 보아야 하므로 sendChat함수, readChatList함수를 순차적으로 호출하도록 하였습니다.
이어서 좌측 컴포넌트의 채팅룸을 구현하도록 하겠습니다.
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import SearchBar from './common/InputBar';
import SearchButton from './common/InpuButton';
import MessageCard from './MessageCard';
import { useDispatch, useSelector } from 'react-redux';
import { readUserList } from '../../modules/messageList';
import { changeField } from '../../modules/send';
import { withRouter } from 'react-router-dom';
import palette from '../../lib/styles/palettes';
import { formatMs } from '@material-ui/core';
const Wrap = styled.div`
float: left;
padding-top: 150px;
width: 30%;
height: calc(100vh - 150px);
overflow-x: hidden;
overflow-y: auto;
border-right: 1px solid ${palette.gray[3]};
`;
const Header = styled.div`
width: 80%;
display: flex;
align-items: center;
justify-content: center;
padding-left: 1.5rem;
`;
const ListBox = styled.div`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
padding-top: 20px;
width: 100%;
`;
const EmptyBox = styled.div`
width: 100%;
display: flex;
justify-content: center;
align-items: center;
`;
const MessageListContainer = () => {
const dispatch = useDispatch();
const {
nickname,
sender,
userList,
error
} = useSelector(({
messageList,
send,
user
}) => ({
nickname: user.user.nickname,
sender: send.sender,
userList: messageList.userList,
error: messageList.error,
}));
useEffect(() => {
dispatch(readUserList(sender));
}, [dispatch, sender]);
useEffect(() => {
dispatch(changeField({
key: 'sender',
value: nickname
}));
}, [dispatch, nickname]);
const onClick = e => {
const item = e.target.innerText.split(' ');
dispatch(changeField({
key: 'receiver',
value: item[0]
}));
};
useEffect(() => {
userList &&
userList.forEach((user, index, array) => {
for(var i = 0; i < userList.length; i++) {
if(index === i) {
continue;
} else if(
user.sender === userList[i].receiver &&
user.receiver === userList[i].sender
) {
userList.splice(i, 1);
}
}
});
}, [userList]);
return(
<Wrap>
<Header>
<SearchBar placeholder="닉네임을 입력해주세요" />
<SearchButton text="검색" />
</Header>
<ListBox>
{
userList ?
userList.map((item, i) => {
return <MessageCard item={ item }
i={ i }
onClick={ onClick }
/>
}) :
<EmptyBox>
메시지함이 비어있습니다!
</EmptyBox>
}
</ListBox>
</Wrap>
);
};
export default withRouter(MessageListContainer);
onClick, userList 중복 제거에 대한 부분을 설명해야 할 것 같습니다.
1) onClick: e.target.innerText.split(' ')의 경우 영역 선택 시 해당 영역 내에 존재하는 텍스트를 공백 단위로 자르는 메서드입니다. 제 경우에는 영역 내에 '? 님과의 채팅'이라는 텍스트가 존재하므로 ?, 님과의, 채팅으로 배열의 요소를 갖게 되며 첫 번째 요소인 ?를 receiver state에 저장합니다. 즉 유저리스트 요소를 클릭하게 되면 선택한 유저의 nickname을 receiver에 저장하는 메서드입니다.
2) userList 중복 제거: 채팅을 보내면 sender와 receiver가 반대가 되는 경우가 있습니다. 즉, MessageRepsitory의 sql을 실행하면 sender: a, receiver:b / receiver: a, sender: b 이렇게 2가지가 한 리스트안에 담겨 오는데 이 중복 케이스를 제거하기 위해 아래의 코드를 이용했습니다.
useEffect(() => {
userList &&
userList.forEach((user, index, array) => {
for(var i = 0; i < userList.length; i++) {
if(index === i) {
continue;
} else if(
user.sender === userList[i].receiver &&
user.receiver === userList[i].sender
) {
userList.splice(i, 1);
}
}
});
}, [userList]);
forEach를 이용하여 리스트안의 요소를 탐색합니다. 한 요소를 기준으로 다시 한 번 for구문을 사용하여 첫 번째 조건문에서는 자기 자신을 제외하고, 두 번째 조건문에서 sender와 receiver가 서로 반대되는 경우를 찾아 리스트에서 제거하도록 합니다. 그리고 중복이 제거된 리스트를 map함수를 이용해서 MessageCard에 담아 유저에게 보여줍니다.
import React from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
const Card = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 80px;
&:hover {
background-color: ${palette.gray[2]}
}
padding-top: 1rem;
padding-left: 4rem;
cursor: pointer;
`;
const Nickname = styled.div`
width: 100%;
height: 40px;
`;
const MessageCard = ({
item,
i,
onClick,
}) => {
const { nickname } = useSelector(({ user }) => ({ nickname: user.user.nickname }));
return(
<Card onClick={ onClick }>
<Nickname>
{ item.receiver === nickname ? item.sender : item.receiver } 님과의 채팅
</Nickname>
</Card>
);
};
export default MessageCard;
현재 로그인한 유저의 값을 가진 데이터외의 다른 데이터를 선택하여 대화 상대의 닉네임이 유저에게 보여지게 됩니다.
마지막으로 우측 컴포넌트인 채팅창을 만들도록 하겠습니다.
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { withRouter } from 'react-router-dom';
import styled from 'styled-components';
import { readChatList } from '../../modules/messageList';
import { changeField } from '../../modules/send';
import RoomTemplate from './RoomTemplate';
import SendForm from './SendForm';
const Wrap = styled.div`
padding-top: 150px;
width: 69%;
height: 600px;
overflow-x: hidden;
overflow-y: auto;
`;
const RoomContainer = ({ match }) => {
const dispatch = useDispatch();
const {
sender,
receiver,
chatList,
} = useSelector(({
send,
messageList,
user,
}) => ({
sender: user.user.nickname,
receiver: send.receiver,
chatList: messageList.chatList
}));
useEffect(() => {
dispatch(readChatList({
sender,
receiver,
}));
}, [dispatch, sender, receiver]);
useEffect(() => {
dispatch(changeField({
key: 'sender',
value: sender
}));
}, [dispatch, sender]);
return(
<Wrap>
<RoomTemplate chatList={ chatList }/>
<SendForm />
</Wrap>
);
};
export default withRouter(RoomContainer);
readChatList는 메시지 아이콘 클릭 시 선택된 receiver, 좌측 컴포넌트의 onClick 메서드를 이용해 선택된 receiver의 값 그리고 현재 로그인한 유저인 sender를 이용하여 이 둘과 관련된 모든 메시지를 가져오는 함수입니다. 이를 이용하여 가져온 chatList를 RoomTemplate 컴포넌트에 보냅니다.
import React from 'react';
import styled from 'styled-components';
import ChatCard from './ChatCard';
const Wrap = styled.div`
width: 100%;
height: 560px;
`;
const RoomTemplate = ({ chatList }) => {
return(
<Wrap>
{
chatList ?
chatList.map((item, i) => {
return <ChatCard item={ item }/>
}) :
<></>
}
</Wrap>
);
};
export default RoomTemplate;
import React from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
const Card = styled.div`
width: 100%;
`;
const SenderDate = styled.span`
float: left;
width: 98.5%;
text-align: right;
`;
const ReceiverDate = styled.span`
float: left;
width: 100%;
padding-left: 15px;
`;
const SenderCard = styled.div`
float: right;
display: inline-block;
padding: 20px;
margin: 10px;
background-color: ${palette.blue[2]};
color: white;
border-radius: 10px;
`;
const ReceiverCard = styled.div`
float: left;
display: inline-block;
padding: 20px;
margin: 10px;
background-color: ${palette.gray[2]};
border-radius: 10px;
`;
const ChatCard = ({ item }) => {
const { nickname } = useSelector(({ user }) => ({ nickname: user.user.nickname }));
return(
<>
{
item.sender === nickname ?
<Card>
<SenderCard>
{ item.content }
</SenderCard>
<SenderDate>
{ item.createdAt }
</SenderDate>
</Card> :
<Card>
<ReceiverCard>
{ item.content }
</ReceiverCard>
<ReceiverDate>
{ item.createdAt }
</ReceiverDate>
</Card>
}
</>
);
};
export default ChatCard;
sender가 현재 로그인한 유저라면 SenderCard 컴포넌트에 담도록 하며, 그게 아니라면 Receiver 컴포넌트에 담아 반대되는 경우에도 sender는 현재 로그인한 유저, 상대방은 receiver가 되어 서로 대화하는 형식이 됩니다. 그러면 테스트를 진행하도록 하겠습니다.
1) 메시지 아이콘 클릭 후 채팅페이지로 이동
현재 로그인한 유저의 닉네임입니다.
asd의 게시물에 있는 아이콘을 클릭합니다.
asd와의 채팅 화면입니다.
현재 messages 테이블에 있는 채팅 기록들입니다. asd, bbb은 총 5가지의 채팅이 이루어졌으며 이 리스트들을 잘 가져오는 모습을 확인할 수 있습니다.
2) 메시지 보내기
asdasd 닉네임을 가진 유저로 로그인하여 메시지를 보내도록 하겠습니다.
메시지가 잘 보내지는 모습입니다.
3) 유저 리스트
앞서 2명의 유저가 asd닉네임을 가진 유저에게 메시지를 보냈습니다. 즉, 유저리스트에는 2명의 유저가 있어야겠죠. 잘 있는지 확인을 하겠습니다.
2명의 유저가 유저리스트에 있음이 확인 되었습니다. 그러면 유저 리스트를 클릭하여 채팅창이 호출되는지 확인하겠습니다.
4) 유저 리스트 클릭 시 채팅 리스트 호출
asdasd 유저와의 채팅창 화면
bbb와의 채팅창 화면
2명의 유저와의 채팅이 잘 호출이 되네요. 이렇게 message-service에 대한 구현도 마무리가 되었습니다. 다음 포스트에서는 대여 관련해서 rental-service를 구현하도록 하겠습니다.