👀 깃허브 링크
😎 클론 사이트 바로가기
이슈가 있었던 부분은 제목 옆에 ❗를 붙였습니다.
Main은 크게 Left Bar / Main / Right Bar로 구분했습니다.
Left Bar | Main | Right Bar |
---|---|---|
카테고리 | 카테고리별 라우터 이동 | 검색창, 유저 정보 |
- 실시간 업데이트
- 유저 정보 확인
- 로그아웃 가능
- 트윗 작성
- 별도의 버튼 추가 (홈이 아닌 다른 페이지에서 글을 쓰고자 할 때)
- 이미지 추가 및 삭제 가능
- 이모지 추가 (pc 버전에서만 지원하도록)
- 수정/삭제
- 반응형 액션 (답글, 리트윗, 좋아요, 북마크)
- 검색창 및 팔로우 할 유저 추천 추가
- 유저 팔로우, 언팔로우 가능
- 새로고침 시 유저 목록 랜덤으로 노출
- Firebase에서는 데이터를 받아올 수 있는 api가 크게
getDocs()
와onSnapshot()
가 있는데, 저는 실시간으로 값을 주고 받을 수 있는onSnapShot()
을 각 라우터에 사용하여 적용시켰습니다.
- 데이터를 한 번만 받아올 수 있는
getDocs()
를 사용하게 되면 무분별한 렌더링이 발생하지 않고, 서버에 과부하를 주지 않아 좋긴 하지만, 유저 정보·트윗·행동의 정보를 실시간으로 노출해야 하는 사이트의 특성상onSnapShot()
을 사용하게 되었습니다.
- 프로필, 트윗을 클릭하면 해당 부분에 맞는 url로 이동하게 했습니다.
react-router-dom
의useHistory()
를 사용했습니다.
- Left Bar 영역 하단에 유저 박스를 노출 시켰고, 클릭 시 모달창이 활성화 되어 로그아웃도 할 수 있게 했습니다.
- 로그아웃 방법은 2가지로 1가지는 위와 같고, 다른 1가지는 '프로필' 라우터에서 할 수 있습니다.
- 글을 작성 할 때 글이나 이미지가 없을 때 '트윗하기' 버튼이 비활성화 되게 했고, 글 없이 이미지만 트윗할 수 없게 했습니다.
- 업로드 시 progress bar를 노출시켜 사용자가 상황을 볼 수 있게 했습니다.
- Firebase의 실시간 정보 진행률을 어떻게 받을 수 있는지 아직 모르겠어서 실제 진행 상황률이 아닌 시각적인 용도로만 표시했습니다.. 추후 구현 로직을 알게 된다면 수정할 예정입니다!
- 다른 페이지에서도 글을 쓸 수 있게 Left Bar에 '트윗하기' 버튼을 별도로 만들었습니다.
- Modal 구현은
Material-UI
라이브러리를 사용했는데, 모달 밖 클릭 시 창 닫히는 게 내장되어 있고 쉽게 구현할 수 있기에 선택했습니다.
// nweet = 글 / attachment = 이미지
// 코드 생략
const onSubmit = async (e) => {
e.preventDefault();
let attachmentUrl = "";
setProgressBarCount(0); // 프로그레스 바 초기화
// 입력 값 없을 시 업로드 X
if (nweet !== "") {
// 이미지 있을 때만 첨부
if (attachment !== "") {
//파일 경로 참조 만들기
const attachmentfileRef = ref(storageService, `${userObj.uid}/${v4()}`);
//storage 참조 경로로 파일 업로드 하기
await uploadString(attachmentfileRef, attachment, "data_url");
//storage 참조 경로에 있는 파일의 URL을 다운로드해서 attachmentUrl 변수에 넣어서 업데이트
attachmentUrl = await getDownloadURL(ref(attachmentfileRef));
}
const attachmentNweet = {
text: nweet,
createdAt: Date.now(),
creatorId: userObj.uid,
attachmentUrl,
email: userObj.email,
like: [],
reNweet: [],
replyId: [],
};
const addNweet = async () => {
await addDoc(collection(dbService, "nweets"), attachmentNweet)
.then(() => {
setProgressBarCount(0); // 프로그레스 바 초기화
setNweet("");
setAttachment("");
if (!nweetModal) {
textRef.current.style.height = "52px";
} else {
setNweetModal(false);
}
})
.catch((error) => {
// 에러 처리
console.error("Error adding document: ", error);
setProgressBarCount(0); // 프로그레스 바 초기화
clearInterval(interval);
});
};
let start = 0;
const interval = setInterval(() => {
if (start <= 100) {
setProgressBarCount((prev) => (prev === 100 ? 100 : prev + 1));
start++; // progress 증가
}
if (start === 100) {
addNweet().then(() => {
clearInterval(interval);
});
}
});
} else {
alert("글자를 입력하세요");
}
};
return (
<>
{progressBarCount !== 0 && <BarLoader count={progressBarCount} />}
// ... 생략
</>
)
const BarLoader = ({ count }) => {
return (
<div className={styled.loader}>
<div
className={styled.loader__bar}
style={{
width: `${count}%`,
}}
/>
</div>
);
};
export const NweetModal = ({ nweetModal, userObj, setNweetModal }) => {
const currentProgressBar = useSelector((state) => state.user.load);
return (
<Modal
open={nweetModal}
onClose={() => setNweetModal(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<div className={styled.container}>
<div className={styled.topBox}>
<div className={styled.close} onClick={() => setNweetModal(false)}>
<GrClose />
</div>
</div>
<div className={styled.editInput__container}>
{currentProgressBar?.load && nweetModal && <BarLoader />}
<NweetFactory
userObj={userObj}
setNweetModal={setNweetModal}
nweetModal={nweetModal}
/>
</div>
</div>
</Modal>
);
};
- 이미지는 자체적으로 지원해주는
FileReader
,FileReader.readAsDataURL()
,FileReader
api를 사용했습니다.
- 파일의 용량이 클 때 업로드가 안 되기 때문에 이미지 크기와 사이즈를 압축(조정)해줄 수 있는
browser-image-compression
라이브러리를 사용했습니다.
const [attachment, setAttachment] = useState("");
const onFileChange = async (e) => {
const {
target: { files },
} = e;
const theFile = files[0]; // 파일 1개만 첨부
const compressedImage = await compressImage(theFile); // 이미지 압축
const reader = new FileReader(); // 파일 이름 읽기
/* 파일 선택 누르고 이미지 한 개 선택 뒤 다시 파일선택 누르고 취소 누르면
Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'. 이런 오류가 나옴.
-> if문으로 예외 처리 */
if (theFile) {
reader.readAsDataURL(compressedImage);
}
reader.onloadend = (finishedEvent) => {
const {
currentTarget: { result },
} = finishedEvent;
setAttachment(result);
};
};
const onClearAttachment = () => {
setAttachment("");
fileInput.current.value = ""; // 취소 시 파일 문구 없애기
};
- 이모지는
emoji-picker-router
라이브러리를 사용하여 PC 버전에서만 노출되게 했습니다.
- 이모지 모달 밖 클릭 시 창이 닫히도록 했습니다.
- 이모지 모달에 관련된 것은 custom Hook으로 따로 만들었습니다.
export const useEmojiModalOutClick = (emojiRef, editRef) => {
const [clickEmoji, setClickEmoji] = useState(false);
// 이모지 모달 밖 클릭 시 창 끔
useEffect(() => {
if (!clickEmoji) return;
const handleClick = (e) => {
// node.contains는 주어진 인자가 자손인지 아닌지에 대한 Boolean 값을 리턴함
// emojiRef 내의 클릭한 영역의 타겟이 없으면 true
if (!emojiRef.current.contains(e.target)) {
setClickEmoji(false);
}
};
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
}, [clickEmoji, emojiRef]);
const toggleEmoji = () => {
setClickEmoji(!clickEmoji);
if (clickEmoji) {
setClickEmoji(true);
editRef.current.focus();
}
};
return { clickEmoji, toggleEmoji };
};
- 작성한 트윗이 본인 것인 경우에만 아이콘을 노출시켜 수정/삭제 할 수 있도록 했습니다.
- alert을 이용하여 ok 버튼(true)을 누를 때만 함수가 실행되도록 했습니다.
- 이미지는 처음 트윗을 작성할 때만 추가·변경·삭제 할 수 있도록 트위터·인스타그램처럼 업로드 된 이미지에 대해 건드리지 못하도록 했습니다.
- 트위터와 비슷하게 답글, 리트윗, 좋아요, 북마크를 만들었습니다.
- 답글 로직은 위에 적힌 '트윗 작성'과 비슷합니다.
- 각각의 행동 실행 시 아이콘 옆에 상호작용이 된 만큼의 숫자가 실시간으로 노출됩니다.
- 답글은 해당 트윗을 클릭하고 들어가거나 답글 아이콘을 누르면 모달창이 노출되어 답글을 달 수 있습니다.
- 각각의 행동들을 구현할 때 코드가 반복되어 쓰이기에 custom hooks로 따로 만들어 재사용성을 높였습니다.
// 4개 액션 중 좋아요 컴포넌트인 useToggleLike.js (북마크와 거의 동일)
// nweetObj = 렌더링 된 트윗들 정보 / currentUser = redux store의 일부 값
export const useToggleLike = (nweetObj) => {
const [liked, setLiked] = useState(false);
const currentUser = useSelector((state) => state.user.currentUser);
const toggleLike = async () => {
if (nweetObj.like?.includes(currentUser.email)) {
setLiked(false);
const copy = [...nweetObj.like];
const filter = copy.filter((email) => email !== currentUser.email);
// if (Object.keys(nweetObj).includes("parent") === false) { // 키 존재 여부 확인하는 다른 방법
if (!nweetObj?.parent) { // 답글에서 좋아요 누를 시 원글(부모 parent)가 존재하는지
await updateDoc(doc(dbService, "nweets", nweetObj.id), {
like: filter,
});
} else {
await updateDoc(doc(dbService, "replies", nweetObj.id), {
like: filter,
});
}
} else {
setLiked(true);
const copy = [...nweetObj.like];
copy.push(currentUser.email);
// if (Object.keys(nweetObj).includes("parent") === false) {
if (!nweetObj?.parent) {
await updateDoc(doc(dbService, "nweets", nweetObj.id), {
like: copy,
});
} else {
await updateDoc(doc(dbService, "replies", nweetObj.id), {
like: copy,
});
}
}
};
return { liked, setLiked, toggleLike };
};
// nweetObj = 렌더링 된 트윗들 정보 / reNweetsObj = 리트윗 된 정보들 / currentUser = redux store의 일부 값
export const useToggleReNweet = (reNweetsObj, nweetObj, userObj) => {
const dispatch = useDispatch();
const currentUser = useSelector((state) => state.user.currentUser);
const [reNweetsId, setReNweetsId] = useState({});
const [reNweet, setReNweet] = useState(false);
const [time, setTime] = useState(Date.now()); // 시간 저장
useEffect(() => {
if (reNweetsObj) {
const filter = reNweetsObj.filter((obj) => obj.parent === nweetObj.id);
const index = filter.findIndex((obj) => obj?.email === userObj.email);
setReNweetsId(filter[index]);
} else {
return;
}
}, [nweetObj?.id, reNweetsObj, userObj?.email]);
const toggleReNweet = async () => {
if (nweetObj.reNweet?.some((info) => info.email === userObj.email)) {
setReNweet(false);
const copy = [...nweetObj.reNweet];
const filter = copy.filter((info) => {
return info.email !== userObj.email;
});
await updateDoc(doc(dbService, "nweets", nweetObj.id), {
reNweet: filter,
});
const reNweetsRef = doc(dbService, "reNweets", reNweetsId.id);
await deleteDoc(reNweetsRef); // 원글의 reply 삭제
dispatch(
setCurrentUser({
...currentUser,
reNweet: filter,
})
);
} else {
setReNweet(true);
const _nweetReply = {
parentText: nweetObj.text,
creatorId: userObj.uid,
email: userObj.email,
like: [],
reNweetAt: time,
parent: nweetObj.id || null,
parentEmail: nweetObj.email || null,
};
await addDoc(collection(dbService, "reNweets"), _nweetReply);
const copy = [...nweetObj.reNweet, {email: userObj.email, reNweetAt: Date.now()}];
await updateDoc(doc(dbService, "nweets", nweetObj.id), {
reNweet: copy,
});
dispatch(
setCurrentUser({
...currentUser,
reNweet: copy,
})
);
}
};
return { reNweet, setReNweet, toggleReNweet };
};
- 검색창은 본인 것을 제외한 트윗과 유저를 검색할 수 있게 했습니다.
- 타이핑마다 함수를 실행하고 렌더링 되는 것을 방지하고자 손쉽게 debounce를 구현할 수 있는
lodash
라이브러리를 사용했습니다.- 검색 목록에 노출되는 글은 클릭 시 해당 라우터로 이동하게 했습니다.
- 검색창은
useLocation
와includes()
메소드를 사용해 '탐색하기' 페이지에서만 사라지도록 했습니다.location.pathname.includes("explore")
팔로우 유저는onSnapshot()
을 사용하면 팔로우 할 때마다 리렌더링과 랜덤으로 목록이 바뀌어 팔로우한 유저가 뒤섞이기 때문에, 이러한 점을 방지하고자getDocs()
를 사용했습니다. 처음 렌더링 시에만 랜덤으로 노출되게 하고 그 후에는 새로고침 아이콘을 눌러 랜덤으로 섞일 수 있게 했습니다.
- (수정)
getDocs()
사용 할 때 업데이트 되어야 할 필드값이 바뀌지 않아onSnapshot()
을 사용했고, 실시간 업데이트 시 랜덤함수가 계속 실행되는 이슈로 기능 제외했습니다..
// focus 영역 밖 클릭 시 닫히는 custom hook
const { nweetEtc: focus, setNweetEtc: setFocus } =
useNweetEctModalClick(searchRef);
// 클릭 시 검색창 focus
const onClick = useCallback(
(e) => {
setFocus(true);
textRef.current.focus();
},
[setFocus]
);
// 검색 내역 리스트
useEffect(() => {
// 유저 정보
const userInfo = async () => {
const q = query(collection(dbService, "users"));
const data = await getDocs(q);
const userArray = data.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
// 본인 제외 노출
const exceptArray = userArray.filter((name) => name.uid !== userObj.uid);
setUsers(exceptArray);
};
// 트윗 정보
const nweetInfo = async () => {
const q = query(collection(dbService, "nweets"));
onSnapshot(
q,
(snapshot) => {
const nweetArray = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
// 본인 제외 노출
const exceptArray = nweetArray.filter(
(nweet) => nweet.creatorId !== userObj.uid
);
setNweets(exceptArray);
},
[]
);
};
userInfo();
nweetInfo();
}, [userObj]);
useEffect(() => {
// 닉네임/이메일 검색
if (focus && search !== "") {
const filterNameAndEmail = users?.filter(
(user) =>
user.displayName.includes(search) ||
user.email.split("@")[0].includes(search)
);
setUserResult(filterNameAndEmail);
setLoading(true);
} else {
setUserResult("");
}
// 트윗 검색
if (focus && search !== "") {
const filterNweets = nweets?.filter((nweet) =>
nweet.text.includes(search)
);
setNweetResult(filterNweets);
setLoading(true);
} else {
setNweetResult("");
}
}, [focus, nweets, search, users]);
// - 방법 1
const onChange = useCallback((e) => {
textRef.current.focus();
setTimeout(() => {
setSearch(e.target.value);
}, 200);
}, []);
useEffect(() => {
return () => {
clearTimeout(onChange);
};
}, [onChange]);
// - ✔ 방법 2
const onChange = debounce((e) => {
textRef.current.focus();
setSearch(e.target.value);
}, 200);
const [refresh, setRefresh] = useState(false);
useEffect(() => {
const userInfo = async () => {
const q = query(collection(dbService, "users"));
const data = await getDocs(q);
return data.docs.map((doc) => doc.data());
};
userInfo().then((userArray) => {
// 본인 제외 노출
const exceptArray = userArray.filter(
(name) => name.email !== myInfo?.email
);
// 팔로우 안 되어 있는 유저
const notFollowed = exceptArray?.filter(
(res) => !myInfo?.following.includes(res.email)
);
let cloneArr = cloneDeep(notFollowed); // 깊은 복사
randomArray(cloneArr);
setCreatorInfo(cloneArr);
setLoading(true);
});
}, [myInfo?.email, refresh]);
// 랜덤 함수
const randomArray = (array) => {
// (피셔-예이츠)
for (let index = array?.length - 1; index > 0; index--) {
// 무작위 index 값을 만든다. (0 이상의 배열 길이 값)
const randomPosition = Math.floor(Math.random() * (index + 1));
// 임시로 원본 값을 저장하고, randomPosition을 사용해 배열 요소를 섞는다.
const temporary = array[index];
array[index] = array[randomPosition];
array[randomPosition] = temporary;
}
};
const onRefresh = () => {
setRefresh(!refresh);
};
- 이모지 모달이 열리고 이모티콘을 클릭할 때마다 textarea가 버벅이는 현상이 있었습니다.
- 이모지 모달을 감싸고 있는 부분에 조건부로 true일 때만 나타나도록 하여 해결할 수 있었습니다.
// 해결: clickEmoji이 true일 때만 실행해서 textarea가 버벅이지 않음
{clickEmoji && (
<div
className={`
${styled.emoji} ${clickEmoji ? styled.emoji__block : styled.emoji__hidden}
`}
>
<Picker
onEmojiClick={onEmojiClick}
disableAutoFocus={true}
/>
</div>
)}
- 업로드 실패
- 파일을 선택하고 업로드를 하려니까 크기가 너무 크다고 업로드가 안 되는 에러가 있었습니다. 이에 구글링을 하던 중 적합한 라이브러리인
browser-image-compression
를 설치하여 요구에 맞게 해결이 되었습니다.
- 이미지 선택
- 추가·변경·삭제 과정을 테스트 하던 중
Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'
이라는 에러가 떠서 확인해보니, 파일을 선택 -> 재선택 -> 취소 과정에서 파일을 제대로 읽을 수 없었기에 에러가 발생했던 것 같습니다. 그래서 따로 if문으로 true일 때만 작동하도록 했습니다.
// '업로드 실패' 이슈 해결 -> 이미지 압축
const compressImage = async (image) => {
try {
const options = {
maxSizeMb: 1, // 용량 선택
maxWidthOrHeight: 800, // 사이즈 가로, 세로 선택
};
return await imageCompression(image, options);
} catch (error) {
console.log(error);
}
};
const compressedImage = await compressImage(theFile); // 이미지 압축
// '이미지 선택' 이슈 해결 방안
if (theFile) {
reader.readAsDataURL(compressedImage);
}
- 트윗(원글)과 답글들을 리트윗 활성화/비활성화 할 때
TypeError: Cannot read properties of undefined
라는 에러가 발생했었습니다.
- 원글과 답글의 Firebase 필드값을 몇 개만 빼고 동일하게 설정했었고, 어떠한 곳(다른 라우터)에서든 ReNweet값만 생성/삭제가 되면 문제 없을 거라 생각했었는데, 이것이 문제였었습니다.
에러를 없애려 조금 긴 시간동안 헤맸었는데, 거슬러 올라가며 면밀히 비교해 찾아보니 트윗과 답글의 Firebase 문서의 필드 정보가 달라 키값을 읽지 못해 에러가 발생했던 것이였고, 조금만 더 쉽게 단면적으로 생각했다면 빠르게 해결했었을 문제인데 더 깊게 들어가 생각하다보니 시간이 오래 걸렸던 것 같습니다. undefined가 나오지 않도록 null값을 별도로 추가해 에러를 해결했습니다.
// parent와 parentEmail의 키값이 다른 라우터에는 없을 수 있기에 undefined가 나오지 않도록 논리연산자로 null 값 추가
const _nweetReply = {
parentText: nweetObj.text,
creatorId: userObj.uid,
email: userObj.email,
like: [],
reNweetAt: time,
parent: nweetObj.id || null,
parentEmail: nweetObj.email || null,
};
- 배열을 랜덤으로 바꿔주는 함수 실행 후 팔로우 버튼을 누를 때마다 리렌더링이 되어 유저 순서가 계속 바뀌는 이슈가 있었습니다. 전개 연산자로 복사를 하고 랜덤 함수를 실행하여 진행했으나, 깊은 복사가 되지 않고 원본 배열을 건드리게 되어 이슈가 생겼던 것 같습니다.
lodash
의cloneDeep()
를 사용하여 해결할 수 있었고, 전개 연산자는 한 단계의 배열만 깊은 복사가 이루어진다는 것을 알게 되었습니다.