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

#419.2 채팅 페이지 typescript 전환 / #540 채팅 UI/UX 개선 #594

Merged
merged 19 commits into from
Aug 4, 2023

Conversation

14KGun
Copy link
Member

@14KGun 14KGun commented Jul 15, 2023

Summary

It closes #419 , #540

1. 채팅 관련 component, hook의 typescript 마이그레이션

채팅과 관련된 component와 hook 들을 typescript로 마이그레이션 하였습니다.

2. 채팅 코드 Refactoring

채팅 코드를 기능별로 여러개의 hook으로 모듈화하고 필요없는 자식 컴포넌트로의 props의 전달을 최소화하였습니다.

  • 기존 채팅 컴포넌트의 코드

import axiosOri from "axios";
import PropTypes from "prop-types";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useHistory } from "react-router-dom";
import { useStateWithCallbackLazy } from "use-state-with-callback";
import useDateToken from "hooks/useDateToken";
import { useValueRecoilState } from "hooks/useFetchRecoilState";
import { useR2state } from "hooks/useReactiveState";
import { useAxios, useQuery } from "hooks/useTaxiAPI";
import Container from "./Container";
import Header from "./Header";
import MessageForm from "./MessageForm";
import MessagesBody from "./MessagesBody";
import { checkoutChat, getCleanupChats } from "./utils";
import alertAtom from "atoms/alert";
import { useSetRecoilState } from "recoil";
import convertImg from "tools/convertImg";
import regExpTest from "tools/regExpTest";
import {
registerSocketEventListener,
resetSocketEventListener,
socketReady,
} from "tools/socket";
const Chatting = ({ roomId, layoutType }) => {
const sendingMessage = useRef();
const callingInfScroll = useRef();
const isBottomOnScrollCache = useRef(true);
const messagesBody = useRef();
const history = useHistory();
const axios = useAxios();
const [chats, setChats] = useStateWithCallbackLazy([]);
const [showNewMessage, setShowNewMessage] = useState(false);
const [, setMessageFormHeight] = useStateWithCallbackLazy("48px");
const reactiveState = useR2state();
const prevReactiveState = useRef(reactiveState);
const setAlert = useSetRecoilState(alertAtom);
const { oid: userOid } = useValueRecoilState("loginInfo") || {};
const [headerInfoToken, fetchHeaderInfo] = useDateToken();
const [, headerInfo] = useQuery.get(`/rooms/info?id=${roomId}`, {}, [
headerInfoToken,
]);
useLayoutEffect(() => {
if (!callingInfScroll.current) return;
callingInfScroll.current = false;
let scrollTop = -34; // 34는 ChatDate의 높이
const bodyChildren = messagesBody.current.children;
for (const children of bodyChildren) {
if (children.getAttribute("chatcheckout")) break;
scrollTop += children.clientHeight;
}
messagesBody.current.scrollTop = scrollTop;
}, [chats]);
useEffect(() => {
if (reactiveState !== 3 && prevReactiveState.current === 3) {
history.replace(`/myroom/${roomId}`);
}
if (reactiveState === 3 && prevReactiveState.current !== 3)
prevReactiveState.current = reactiveState;
}, [reactiveState]);
// scroll event
const isTopOnScroll = (tol = 0) => {
if (messagesBody.current) {
const scrollTop = Math.max(messagesBody.current.scrollTop, 0);
if (scrollTop <= tol) {
return true;
}
}
return false;
};
const isBottomOnScroll = (tol = 20) => {
if (messagesBody.current) {
const scrollHeight = messagesBody.current.scrollHeight;
const scrollTop = Math.max(messagesBody.current.scrollTop, 0);
const clientHeight = messagesBody.current.clientHeight;
const scrollBottom = Math.max(scrollHeight - clientHeight - scrollTop, 0);
if (scrollBottom <= tol) {
return true;
}
}
return false;
};
const handleScroll = () => {
if (isBottomOnScroll()) {
if (showNewMessage) setShowNewMessage(false);
isBottomOnScrollCache.current = true;
} else {
isBottomOnScrollCache.current = false;
}
if (
isTopOnScroll() &&
chats.length > 0 &&
callingInfScroll.current == false
) {
callingInfScroll.current = true;
socketReady(() => {
axios({
url: "/chats/load/before",
method: "post",
data: { roomId, lastMsgDate: chats[0].time },
});
});
}
};
// message Body auto scroll functions
const scrollToBottom = (doAnimation = false) => {
setShowNewMessage(false);
if (messagesBody.current) {
const scrollTop =
messagesBody.current.scrollHeight - messagesBody.current.clientHeight;
if (doAnimation) {
messagesBody.current.scroll({
behavior: "smooth",
top: scrollTop,
});
} else {
messagesBody.current.scrollTop = scrollTop;
}
}
};
// messageForm Height function
const handleMessageFormHeight = (height) => {
let isBottom = isBottomOnScroll();
setMessageFormHeight(height, () => {
if (isBottom) scrollToBottom();
});
};
// socket event
useEffect(() => {
let isExpired = false;
sendingMessage.current = true;
socketReady(() => {
if (isExpired) return;
// socket event listener 등록
registerSocketEventListener({
initListener: (chats) => {
if (isExpired) return;
sendingMessage.current = null;
setChats(getCleanupChats(chats), () => {
scrollToBottom();
callingInfScroll.current = false;
});
},
reconnectListener: () => {
if (isExpired) return;
setChats((prevChats) => {
axios({
url: "/chats/load/after",
method: "post",
data: {
roomId,
lastMsgDate: prevChats[prevChats.length - 1].time,
},
});
return prevChats;
});
},
pushBackListener: (chats) => {
if (isExpired) return;
const isMyMsg = chats.some((chat) => chat.authorId === userOid);
if (isMyMsg) sendingMessage.current = null;
if (chats.length > 10) {
axios({
url: "/chats/load/after",
method: "post",
data: {
roomId,
lastMsgDate: chats[chats.length - 1].time,
},
});
}
setChats(
(prevChats) => getCleanupChats([...prevChats, ...chats]),
isMyMsg || isBottomOnScroll()
? () => scrollToBottom(true)
: () => setShowNewMessage(true)
);
},
pushFrontListener: (chats) => {
if (isExpired) return;
if (chats.length === 0) {
callingInfScroll.current = null;
return;
}
setChats((prevChats) =>
getCleanupChats([...chats, checkoutChat, ...prevChats])
);
},
});
// 채팅 로드 API 호출
axios({
url: "/chats",
method: "post",
data: { roomId },
});
});
return () => {
isExpired = true;
resetSocketEventListener();
};
}, [roomId]);
// resize event
const resizeEvent = () => {
if (isBottomOnScrollCache.current) scrollToBottom();
};
useEffect(() => {
resizeEvent();
window.addEventListener("resize", resizeEvent);
visualViewport?.addEventListener("resize", resizeEvent);
return () => {
window.removeEventListener("resize", resizeEvent);
visualViewport?.removeEventListener("resize", resizeEvent);
};
}, []);
// message function
const sendMessage = (type, content) => {
if (sendingMessage.current) return false;
if (type === "text" && !regExpTest.chatMsg(content)) return false;
if (type === "account" && !regExpTest.account(content)) return false;
sendingMessage.current = true;
axios({
url: "/chats/send",
method: "post",
data: { roomId, type, content },
onError: () => {
sendingMessage.current = null;
setAlert("메시지 전송에 실패하였습니다.");
},
});
return true;
};
const handleSendMessage = (text) => sendMessage("text", text);
const handleSendAccount = (account) => sendMessage("account", account);
const handleSendImage = async (image) => {
if (sendingMessage.current) return;
sendingMessage.current = true;
const onFail = () => {
sendingMessage.current = null;
setAlert("이미지 전송에 실패하였습니다.");
};
try {
image = await convertImg(image);
if (!image) return onFail();
axios({
url: "chats/uploadChatImg/getPUrl",
method: "post",
data: { roomId, type: image.type },
onSuccess: async ({ url, fields, id }) => {
if (!url || !fields) return onFail();
const formData = new FormData();
for (const key in fields) {
formData.append(key, fields[key]);
}
formData.append("file", image);
const { status: s3Status } = await axiosOri.post(url, formData);
if (s3Status !== 204) return onFail();
axios({
url: "chats/uploadChatImg/done",
method: "post",
data: { id },
onSuccess: ({ result }) => {
if (!result) onFail();
},
onError: onFail,
});
},
onError: onFail,
});
} catch (e) {
console.error(e);
onFail();
}
};
return (
<Container layoutType={layoutType}>
<Header
layoutType={layoutType}
info={headerInfo}
recallEvent={fetchHeaderInfo}
/>
<MessagesBody
layoutType={layoutType}
chats={chats}
forwardedRef={messagesBody}
handleScroll={handleScroll}
isBottomOnScroll={isBottomOnScroll}
scrollToBottom={() => scrollToBottom(false)}
/>
<MessageForm
layoutType={layoutType}
handleSendMessage={handleSendMessage}
handleSendImage={handleSendImage}
handleSendAccount={handleSendAccount}
showNewMessage={showNewMessage}
onClickNewMessage={() => scrollToBottom(true)}
setContHeight={handleMessageFormHeight}
/>
</Container>
);
};
Chatting.propTypes = {
layoutType: PropTypes.oneOf(["sidechat", "fullchat"]),
roomId: PropTypes.string,
};
export default Chatting;

  • 리펙토링 이후 채팅 컴포넌트 코드

import { useRef, useState } from "react";
import { useStateWithCallbackLazy } from "use-state-with-callback";
import { LayoutType } from "types/chat";
import type { Chats } from "types/chat";
import useBodyScrollControllerEffect from "hooks/chat/useBodyScrollControllerEffect";
import useSendMessage from "hooks/chat/useSendMessage";
import useSocketChatEffect from "hooks/chat/useSocketChatEffect";
import useDateToken from "hooks/useDateToken";
import useDisableScrollEffect from "hooks/useDisableScrollEffect";
import useQuery from "hooks/useTaxiAPI";
import Container from "./Container";
import Header from "./Header";
import MessageForm from "./MessageForm";
import MessagesBody from "./MessagesBody";
type ChatProps = {
roomId: string;
layoutType: LayoutType;
};
const Chat = ({ roomId, layoutType }: ChatProps) => {
const [chats, setChats] = useStateWithCallbackLazy<Chats>([]); // 채팅 메시지 배열
const [isDisplayNewMessage, setDisplayNewMessage] = useState<boolean>(false); // 새로운 메시지 버튼 표시 여부
const [isOpenToolSheet, setIsOpenToolSheet] = useState<boolean>(false); // 툴 시트 표시 여부
const messageBodyRef = useRef<HTMLDivElement>(null); // 스크롤 되는 메시지 HTML 요소
const isSendingMessage = useRef<boolean>(false); // 메시지 전송 중인지 여부
const sendMessage = useSendMessage(roomId, isSendingMessage); // 메시지 전송 핸들러
// 방 정보 조회
const [roomInfoToken, fetchRoomInfo] = useDateToken();
const [, roomInfo] = useQuery.get(`/rooms/info?id=${roomId}`, {}, [
roomInfoToken,
]);
// socket.io를 통해 채팅 전송 및 수신
useSocketChatEffect(
roomId,
fetchRoomInfo,
setChats,
setDisplayNewMessage,
messageBodyRef,
isSendingMessage
);
// 채팅의 scroll을 제어
useBodyScrollControllerEffect(
roomId,
chats,
setDisplayNewMessage,
setIsOpenToolSheet,
messageBodyRef
);
// 전체화면 챗에서는 body의 스크롤을 막습니다.
useDisableScrollEffect(layoutType === "fullchat");
return (
<Container layoutType={layoutType}>
<Header
layoutType={layoutType}
roomInfo={roomInfo}
fetchRoomInfo={fetchRoomInfo}
/>
<MessagesBody
layoutType={layoutType}
chats={chats}
ref={messageBodyRef}
/>
<MessageForm
layoutType={layoutType}
roomInfo={roomInfo}
isDisplayNewMessage={isDisplayNewMessage}
isOpenToolSheet={isOpenToolSheet}
onChangeIsOpenToolSheet={setIsOpenToolSheet}
messageBodyRef={messageBodyRef}
sendMessage={sendMessage}
/>
</Container>
);
};
export default Chat;

  • 채팅 관련 코드 구조
src
├ components
│ ├ Chat
│ │ ├ Container
│ │ │ └ ...
│ │ ├ Header
│ │ │ └ ...
│ │ ├ MessageForm
│ │ │ └ ...
│ │ ├ MessageBody
│ │ │ └ ...
│ │ └ index.tsx
│ ├ ModalPopup
│ │ ├ ModalChatCancel.tsx
│ │ ├ ModalChatPayment.tsx
│ │ ├ ModalChatSettlement.tsx
│ │ ├ ModalRoomShare.tsx
│ │ └ ...
│ └ ...
├ hooks
│ ├ chat
│ │ ├ useBodyScrollControllerEffect.tsx
│ │ ├ useChatsForBody.tsx
│ │ ├ useIsReadyToLoadImage.tsx
│ │ ├ useSendMessage.tsx
│ │ └ useSocketChatEffect.tsx
│ └ ...
├ tools
│ ├ chat
│ │ ├ chats.ts
│ │ └ scroll.ts
│ └ ...
├ types
│ ├ chat.d.ts
│ └ ...
└ ...

3. 다른 방 채팅 메시지도 수신 버그 해결

#595 에서 발견된 다른 채팅 방의 메시지도 채팅창에 표시되는 버그를 해결하였습니다.
다른 방의 채팅메시지는 @withSang 님이 제시하였던 filter로 거릅니다.

chats = chats.filter((chat) => chat.roomId === roomId);

4. UI 개선

@imYourChoi 의 디자인 수정안을 반영하였습니다.

  • 내방리스트 페이지의 채팅의 분리된 Header 통합 (기존 | 수정후)
스크린샷 2023-08-02 오후 5 34 40 스크린샷 2023-08-02 오후 5 35 19
  • SideMenu 추가 (기존 | 수정후)
  • ToolSheet 추가 (기존 | 수정후)

5. 이미지 채팅 메시지 미리보기 이후 전송

기존에 이미지 채팅 메시지를 운영체제의 파일 탐색기에서 선택 이후 바로 보냈다면,

파일 탐색기에서 선택 이후 미리보기 후 전송 버튼을 눌러야 전송됩니다.

메시지 전송과 이미지 압축 빛 확장자 변환은 비동기적으로 이루어 지며 이 동안 애니메이션 효과를 주었습니다.

2023-07-30.12.55.17.mov

6. 내방리스트 실시간 업데이트

in, out, settlement, payment 타입의 채팅 메시지 수신시 글로벌 내방리스트를 업데이트 합니다.

2023-08-02.4.56.12.mov

Further Works

@14KGun 14KGun added mypage 마이페이지 chatting 🔨refactoring 기존의 코드를 리팩토링합니다 labels Jul 15, 2023
@14KGun 14KGun self-assigned this Jul 15, 2023
@14KGun 14KGun linked an issue Jul 15, 2023 that may be closed by this pull request
2 tasks
This was linked to issues Jul 18, 2023
@14KGun 14KGun linked an issue Jul 19, 2023 that may be closed by this pull request
2 tasks
@predict-woo predict-woo marked this pull request as ready for review July 26, 2023 12:08
@predict-woo
Copy link
Member

헉 제 PR인줄 알았습니다! 죄송합니다ㅜㅠ

@predict-woo predict-woo marked this pull request as draft July 26, 2023 12:10
@14KGun 14KGun changed the title #419.2 채팅 페이지 typescript 전환 #419.2 채팅 페이지 typescript 전환 / #540 채팅 UI/UX 개선 Aug 2, 2023
@14KGun 14KGun marked this pull request as ready for review August 2, 2023 08:55
@14KGun 14KGun merged commit 8e68658 into dev Aug 4, 2023
@14KGun 14KGun deleted the #419.2-typescript-chatting branch August 4, 2023 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
chatting mypage 마이페이지 🔨refactoring 기존의 코드를 리팩토링합니다
Projects
None yet
3 participants