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

[4주차] 오대균 미션 제출합니다. #13

Open
wants to merge 61 commits into
base: master
Choose a base branch
from

Conversation

oooppq
Copy link

@oooppq oooppq commented Nov 1, 2023

배포

CEOS-LINE-PLUS

간단 사용법

  • 접속하게 되면, 기본으로 설정된 main 유저가 '오대균'입니다.

  • 홈 화면에서 원하는 유저를 더블 클릭하면 해당 유저로 main user가 전환됩니다.

  • 채팅 input에 무언가 입력되어 있을 때에 보내기 버튼으로 변경됩니다.

  • 모바일과 데스크탑에서 서로 다르게 메시지를 전송합니다(카톡처럼)

    • 모바일: 보내기 버튼으로 메시지 전송, enter로 줄바꿈
    • 데스크탑: 보내기 버튼 or enter로 메시지 전송, shift + enter로 줄바꿈
  • 전송된 메시지는 처음에는 읽지 않은 상태로 저장됩니다(read 표시가 없음).
    이후, 메시지 수신자로 main 유저를 변경한 후, 해당 채팅방에 접속하면 보낸 메시지가 읽음 처리 됩니다.

  • 채팅바의 왼쪽 메뉴들은 채팅 input이 focus일 때 접히고, 그렇지 않으면 펼쳐져 있습니다. 화살표을 클릭하면 다시 펼쳐집니다.

  • 메시지를 더블클릭하면 좋아요가 표시됩니다. 만약 이미 좋아요한 메시지라면 좋아요가 취소됩니다.

  • 검색기능이 있는 페이지는 모두 해당 기능을 사용할 수 있습니다.

  • 원하는 친구와 새로운 대화를 시작할 수 있습니다.

  • 프로필을 원하는대로 수정할 수 있습니다.

Key Features

  • 홈화면의 유저를 더블 클릭하여 main user 전환
RPReplay_Final1698858444.MP4
  • 검색 기능(홈, 채팅목록, 새로운 채팅)
RPReplay_Final1698858669.MP4
  • 새로운 채팅(페이지 상단 오른쪽에서 두 번째 버튼 클릭)
RPReplay_Final1698858872.MP4
  • 프로필 수정(홈화면의 유저 프로필 부분 클릭)
KakaoTalk_Video_2023-11-02-03-04-41.mp4

정리

디자이너의 결과물을 일말의 오차없이 구현하는 것을 목표로 삼았습니다.
메신저라면 있어야 하는 기능들(메시지 좋아요, 유저 검색, 프로필 수정)을 추가했고,
디자이너분과 소통하며 새로운 기능에 대한 디자인을 요청하여 적용했습니다.
하다보니 재밌어서 시간을 생각보다 많이 쓰게 되었네요 다음 과제도 기대됩니당

목표 달성

  • SPA의 개념 이해
    React Router을 사용하여 SPA지만 다양한 페이지의 라우팅이 기능하도록 구현했습니다. 실제로는 하나의 index.html이지만 url에 따라 컴포넌트 렌더링 상태를 변경하며 이와 같은 효과를 낼 수 있습니다.

  • 디자이너QA
    별다른 QA 사항이 없었습니다. 오히려, 제가 새로운 기능을 추가하느라 디자인을 새로 요청했었습니다. 바쁘신 와중에도 빠르게 대응해주셔서 너무 감사하드라구요.. 귀찮게 해서 죄송하기도 하구..

  • 상태 관리
    useState와 zustand를 사용하여 상태를 관리했습니다. 서버 없이 유저 데이터를 유지하기 위해 localStorage를 활용하면서, localStorage와 in-memory state 간의 차이가 발생하지 않도록 신경썼던 것 같습니다.

  • UI 컴포넌트 중복 줄이기
    styled-components의 가장 큰 장점이 아닐까 싶습니다. 정의해놓은 styled component를 재활용하고 확장하기가 매우 쉽기 때문입니다.

  • 코드 재사용
    중복되는 기능들은 최대한 추상화하여 재사용하기 쉽게 구현했습니다. 다양한 아이콘으로 이뤄진 버튼 컴포넌트를 재사용하기 위해, ButtonWithIcon이라는 컴포넌트를 구성했고, 다양한 페이지에서의 검색기능을 위해 SearchBar 컴포넌트를 정의했습니다.

    컴포넌트 뿐만 아니라, 반복하여 사용되는 함수들을 별도의 파일에 정의하여 재사용할 수 있도록 했습니다. 이런 식으로 코드를 추상화 한 후,
    재사용성을 늘리면 구현하는 데에도 편리하지만 관심사도 분리되어 디버깅도 편하고, 가독성도 매우 깔끔해지는 장점이 있는 것 같습니다.

불필요한 의존성 정리 및 필요한 의존성 추가, typescript 세팅, static files(font, image) 추가, 기본 스타일링 세팅
스타일 적용과 더불어 이미지로 이루어진 버튼을 다루는 컴포넌트 추가
처음에는 className을 전달하여 스타일링했으나, styled-component를 활용하여
스타일링 하는 방향으로 변경했다.
디자인 적용을 완료했고, 그 과정에서 필요한 type이나 util 함수들을 정의했다.
반응형 레이아웃 변화 및 input(textarea) 높이 동적 변경기능 추가
보내기 버튼 눌렀을 때, input이 focus 여부 상태를 그대로 유지하도록 했다.
모바일 환경에서는 enter 누르면 줄바꿈이 되고, 보내기 버튼 눌러야 보내지도록 했고,
데스크탑 환경에서는 enter 누르면 보내기, shift+enter 누르면 줄바꿈 되도록 구현했다.
유저와 메시지를 zustand를 통해 전역 state로 정의 후, 유저 변경 기능을 구현했다.
내가보낸 메시지, 상대가 보낸 메시지 상관없이 더블클릭으로 좋아요를 남길 수 있다.
유저 정보에 좋아요 누른 메시지 id들을 포함하여 이미 누른 메시지라면 취소될 수 있도록 한다.
채팅방을 옮겼을 때 메시지가 읽음 처리되도록 구현했다.
근데 메시지를 읽음 처리 했을 때 전역 state로 선언한 messages가 렌더링되면서
infinite re-rendering 되는 오류가 발생했다. 따라서, zustand가 state를 업데이트 할 때,
얕은 비교로 상태를 비교하여 업데이트 하도록 변경하여 무한 리렌더링 문제를 해결했다.
* 채팅방 이외의 다른 url 접근을 방지했다.
모든 변화에 대한 비교를 shallow로 하면 적절하게 동작하지 않는 경우가 발생했다.
따라서, 읽음 처리의 경우 따로 setter 함수를 만들고, message store에 immer middleware를 적용하여
문제를 해결했다.
* 코드에 대한 주석 작성 완료
ios의 한글 입력 방식이 다른 환경과 다르다고 한다. 때문에 받침이 없는 한글을 입력하고
send button으로 보낸 후 textarea의 focus를 유지시키면 보낸 메시지의 마지막 글자(받침이 없는)가
다음 입력 때 포함된다.
임의의 input element를 생성하고 해당 input에 focus를 옮겼다가 다시 원래의 textarea로 focus를 옮겨주면
문제가 해결된다.
채팅 input padding 좌위 9px 늘림
Layout 컴포넌트를 만들어 NavBar를 포함했고, 홈화면, 채팅목록, 프로필은
NavBar를 표시하도록, 채팅방은 NavBar가 표시되지 않도록 구성했다.
local storage를 사용하여 현재 활성화되어 있는 유저 상태를 따로 기록하도록 수정했다.
채팅목록 기능 구현을 완료했다.
중간에 디자인적인 에러(모바일 디자인적용 안되는 점)도 수정했다.
기존에는 메시지 객체에 프로필사진이 있도록 구성했는데, 논리적으로 말이 안되는 것 같아
채팅방에서 상대방의 프로필 사진을 가져와 message 객체에 추가해주는 형태로 변경했다.
스크롤 동작 간에 어색한 모션이 생겨 전부 position: fixed로 변경했다.
파일을 받아 localStorage에 저장하여 프로필사진을 변경할 수 있도록 기능구현했다.
파일의 사이즈가 큰 경우 localStorage의 메모리를 초과할 수 있어, browser-image-compression 라이브러리를 통해
파일을 압축하는 과정을 거쳐 localStorage에 저장되도록 했다.
새로운 채팅 만들기 기능을 도입하기 위해, 그리고 홈화면과 채팅목록에서
반복되었던 search bar의 redundancy 제거를 위해 컴포넌트로 분리했다.
기존에는 채팅방 상단 클릭으로 유저를 변경했는데,
홈화면에서 친구목록에 있는 유저를 더블 클릭하면 해당 유저로 변경되도록 수정했다.
디자인이 나오면 적용할 예정이다. 기능을 추가하면서 잡다한 타이핑 오류를 수정했다.
자꾸 버튼 내부의 이미지 크기가 제대로 설정되지 않아 아예 컴포넌트 prop으로
크기를 받도록 수정했다.
이전에는 프로필 각각의 요소를 따로따로 수정하도록 구현했었는데, 디자인상으로도 좀 어색하고
굳이 하나하나 버튼을 만드는 것도 비효율적인 것 같아, 헤더 부분에 수정 버튼을 만들고 클릭하면
수정 모드로 변경하도록 구현했다.
그 과정에서 프로필 내부의 컴포넌트들이 너무 커져, 프로필 요소마다 각각의 컴포넌트로 분리했다.
state 변경이 빈번하게 발생할 수 있는 프로필 변경 기능에 대해
변경이 발생하지 않는 컴포넌트를 memoization할 수 있도록 react memo를 통해
최적화를 진행했다.
설명을 위한 주석을 추가했고, 반복되는 코드나 불필요한 코드를 정리했다.
user state를 변경하는 과정에서 각각의 state가 비동기적으로 변경되어
원하지 않는 결과가 발생하여 이를 수정했다.
다양한 비율의 사진들을 모두 1대1 비율로 조정하니까 어색해져
각 사진의 비율이 유지된 채로 가운데 정렬되어 crop되도록 수정했다.
Copy link

@dhshin98 dhshin98 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컴포넌트도 깔끔하게 나누시고, 코드나 변수명도 깔끔하게 짜셔서 보면서 공부하느라 리뷰가 좀 늦어졌네요..! (보면서 그냥 감탄했습니다 ..ㅎ) 저한테 코드 리뷰 �남겨주신 부분들 보면서 대균님 코드 보니까 이렇게 짤 수도 있구나 배웠어요 :) 디자인 디테일이랑 제가 생각하지 못했던 기능들까지 구현하셔서 재밌게 봤습니다. 이번 과제도 수고하셨습니다~~!!

Comment on lines +12 to +19
useEffect(() => {
if (query) {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 700);
}
}, [query]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검색 중 로딩 효과 넣은 아이디어도 좋은 것 같습니다ㅎㅎ

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 한번에 정리해 놓으니까 가독성이 좋아서 좋은것 같네요~! 배워갑니다

Comment on lines +37 to +39
if (compressedFile) {
// file reader로 이미지 encoding
const reader = new FileReader();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 프로필 이미지, status 변경 구현을 해보고 싶었는데 하셔서 재밌는 것 같아요~!
그리고 CompressImage , 시간 처리 함수도 컴포넌트화 하니까 가독성이 훨씬 좋네요!! 👍


useEffect(() => {
localStorage.setItem(`user`, JSON.stringify(user));
localStorage.setItem(`user_${user.id}`, JSON.stringify(user));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 한번에 데이터 처리를 했는데, 채팅방 데이터를 이렇게 관리하는게 효율적이고 좋을 것 같네요!!ㅎㅎ 배우고 갑니다~~

Comment on lines +87 to +106
// 채팅목록 구성을 위한 각 유저와의 마지막 메시지 구하기
export const getLastMessages = (id: number, messages: TMessage[]) => {
const lastMessages: TMessage[] = [];
const checkFirst = Array(userData.data.length + 1).fill(0);

[...messages].reverse().forEach((message) => {
if (message.fromUserId === id || message.toUserId === id) {
const opponent =
message.fromUserId === id ? message.toUserId : message.fromUserId;

if (!checkFirst[opponent]) {
lastMessages.push(message);
checkFirst[opponent] = 1;
}
}
});

return lastMessages;
};

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

마지막 메시지를 하나의 배열로 저장해서 사용하니까 더 효율적인 것 같네요!!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

초성 검색 기능은 생각하지 못했는데 디테일 넘 좋은 것 같아요!

Comment on lines +1 to +23
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { TUser } from 'types';
import userData from 'data/userData.json';

interface TUserStore {
user: TUser;
setUser: (user: TUser) => void;
}

const user: string | null = localStorage.getItem('user');
const storedVersion = localStorage.getItem('version');
const initialUserState: TUser =
user && storedVersion && storedVersion === process.env.REACT_APP_VERSION
? JSON.parse(user)
: userData.data[0]; // default로 설정되는 유저는 user_1

export const useUserStore = create(
devtools<TUserStore>((set) => ({
user: initialUserState,
setUser: (newUser: TUser) => set({ user: newUser }),
}))
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zustand 를 통한 전역상태관리를 처음 봐서 공부해봤는데, 다음에는 저도 사용해보고 싶네요!!👍

Copy link

@westofsky westofsky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 프론트운영진 배성준입니다~~!!!!!!!!!!!!!!

엄청난 기능들에 이것저것 막 눌러보면서 신기해하며 리뷰했습니다!!!!
코드도 되게 깔끔하게 작성하시고 typescript 잘 사용하시는 것 같아 많이 배워갑니당
메신저 내에 디테일도 되게 신경쓰신 것 같아 재밌었습니다~~~ 과제하느라 고생하셨어요

Comment on lines +4 to +9
import { ReactComponent as PlusIcon } from 'static/images/plus-icon.svg';
import { ReactComponent as CameraIcon } from 'static/images/camera-icon.svg';
import { ReactComponent as PicIcon } from 'static/images/pic-icon.svg';
import { ReactComponent as MicIcon } from 'static/images/mic-icon.svg';
import { ReactComponent as SendIcon } from 'static/images/send-icon.svg';
import { ReactComponent as BackIcon } from 'static/images/back-arrow-icon.svg';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

svg들을 ReactComponent로 불러와서 사용하신 점 인상깊어요 !! 이렇게 여러개의 파일을 import해서 가져오면 너무 길어져서
react svg파일 관리 한번 읽어보시고 index.ts 생성해서 관리하시는 것도 참고해주세요!!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

옹 이런방법이 있네요?? 상당히 유용할 것 같아요 담 프로젝트에 써먹어야겠습니당 고마워요~~

Comment on lines +20 to +46
<ChatListBodyContainer>
{lastMessages.map((message) => {
// 해당 대화의 상대방 찾기
const opponentId =
user.id === message.fromUserId
? message.toUserId
: message.fromUserId;
const storedOpponent = localStorage.getItem(`user_${opponentId}`);
const opponentUser = storedOpponent
? JSON.parse(storedOpponent)
: userData.data.find((userToCheck) => userToCheck.id === opponentId)!;
//만약 검색어가 있다면 검색어에 해당하는 채팅방만 display
if (!include(opponentUser.name, query)) return null;
return (
<ChatListElement
key={`${message.time}${message.id}`}
chatRoomInfo={{
id: opponentId,
profileImage: opponentUser.profileImage,
userName: opponentUser.name,
message: message.text,
time: message.time,
}}
/>
);
})}
</ChatListBodyContainer>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재밌게 갖고 놀다가 .. 친구 중 한명의 이름을 오대균으로 했는데 검색할 땐 id : 1인 오대균 하나만 뜨더라구요 ??? 왜그런걸까요

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그거는 사실 같은 이름의 유저가 있지 않다고 가정하고 코드 짜서 find 함수 사용했기 때매 그런 것 같아요 생각해보니까 유저이름이 똑같을 수가 있네여ㅋㅋ..

Comment on lines +20 to +26
const storedVersion = localStorage.getItem('version');
if (!storedVersion || storedVersion < process.env.REACT_APP_VERSION!) {
localStorage.clear();
localStorage.setItem('version', process.env.REACT_APP_VERSION!);
// return;
}
}, []);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

버전까지 관리하시다니.. 상상력 대박티비

Comment on lines +39 to +65
try {
// 혹시 localStorage에 저장된 data가 변경되는 상황을 대비
if (user.likedMessages.includes(messageId)) {
// 현재 유저가 이미 좋아한 메시지라면 좋아요 취소
newMessages[clickedIdx] = {
...newMessages[clickedIdx],
likeCount: newMessages[clickedIdx].likeCount - 1,
};
setMessages(newMessages);
setUser({
...user,
likedMessages: user.likedMessages.filter((id) => id !== messageId),
});
} else {
// 이미 좋아요한 메시지가 아니라면 좋아요
newMessages[clickedIdx] = {
...newMessages[clickedIdx],
likeCount: newMessages[clickedIdx].likeCount + 1,
};
setMessages(newMessages);
setUser({ ...user, likedMessages: [...user.likedMessages, messageId] });
}
} catch {
localStorage.clear();
window.location.reload();
}
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

대화에서 하트누르면 다른 사람과 한 대화 내용이 사라집니다 ..! 하트 누른 메세지가 있는 사람과의 대화만 남아있어요

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아오 그러네요 왜 몰랐을까요.. 세션전까지 함 고쳐볼게요 알려주셔서 고마워요~~

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

약간 좋아요 기능을 3주차에 채팅방 하나만 다룰 때 개발했다보니 놓친 오류 같네여 마음이 너무 아픕니다..

Comment on lines +47 to +60
const handleSubmitMessage = () => {
if (content.trim()) {
sendMessage(content);
setContent('');
// focus 상태에서 전송을 누르면, 계속 focus 유지되도록
if (isInputFocused) {
// hiddenInput에 focus를 옮기고, 다시 input으로 옮기는 방식을 사용하여
// ios 환경에서 한글(받침없는 글자) 입력시 buffer가 남아있는 문제를 해결했음
hiddenInputRef.current?.focus();
inputRef.current?.focus();
}
if (inputRef.current) inputRef.current.style.height = '36px';
}
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

textarea에 ref도 설정해주셨는데 친구 검색과 같은 실시간으로 값에 따라 변화가 있어야하는 경우가 아닐 때에 비제어 컴포넌트로 관리할 수 있을 것 같아요!! 비제어 컴포넌트 참고할만한 링크 남길게요!

Comment on lines +34 to +53
<ButtonWithIcon
children={
isStatusMessageSpread ? (
<MoreOnIcon
onClick={(e) => {
foldStatusMessage();
e.stopPropagation();
}}
/>
) : (
<MoreOffIcon
onClick={(e) => {
spreadStatusMessage();
e.stopPropagation();
}}
/>
)
}
size={28}
/>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 아이콘 영역 살짝 밖으로 나가면 프로필로 이동해서 아이콘 영역일 땐 cursor:pointer을 줘서 유저가 헷갈리지 않게 해주면 좋을 것 같아요!

Comment on lines +12 to +19
useEffect(() => {
if (query) {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 700);
}
}, [query]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로딩 기분 너무 좋아요~~

Comment on lines +18 to +32
<div className="title-outer">
<div className="title">Friends</div>
<div className="friend-number">{users.length}</div>
</div>
<div className="friend-list">
{users.map((e) => (
<FriendListElement
key={`${e.id}${e.statusMessage}`}
user={e}
handleDoubleClickUser={() => {
setUser(e);
}}
/>
))}
</div>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

대균님은 안에 component들을 styled component로 정해서 스타일 주시거나 아니면 이렇게 className으로 접근하셔서 스타일 주실 때도 있는데 혹시 어떠한 상황에 따라 쓰시나요 ??? 궁금합니다!!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 스타일링 할 요소가 많지 않다면 하나하나 styled component로 정하기보다 위와 같이 className을 쓰는 것 같아요,
근데 기준이 확실하지 않아서 개인적으로도 좀 애매한 감이 있어서 요즘은 styled components 말고 tailwind를 선호하는 편이에용

Copy link

@geeoneee geeoneee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가적으로 구현하신 것도 많고, 전체적으로 배워갈 점이 많았습니다! 과제 수고 많으셨습니다:)

localStorage.setItem('version', process.env.REACT_APP_VERSION!);
// return;
}
}, []);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localstorage에 저장되어 있는 데이터 버전이 다를 때만 초기화하는 방법 좋은 것 같습니다!

/>
</SearchBarContainer>
);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 방식으로 SearchBar를 컴포넌트로 만드는 것도 편리할 것 같네요!! 배워 갑니당:)

const HomeBody = ({ query }: HomeBodyProps) => {
const user = useUserStore((state) => state.user);
const setUser = useUserStore((state) => state.setUser);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여러 사용자로 바꾸는 것을 이런 식으로 구현할 수 있군요..! 배워 갑니당:)

display: flex;
align-items: center;
padding: 0 12px;
`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동적으로 css 적용하는 것 배워가요!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants