[React] 트위터 클론 (+ Firebase) - Home (Main)

jjjjj·2022년 10월 4일
1

트위터 클론

목록 보기
3/6

👀 깃허브 링크
😎 클론 사이트 바로가기


⚠ 아직 부족한 점이 많아 정보 및 코드가 올바르지 않을 수 있습니다. 이 점 양해 부탁드리며 수정이 필요한 부분은 피드백 주시면 감사하겠습니다!

이슈가 있었던 부분은 제목 옆에 ❗를 붙였습니다.


🔍 구조 살펴보기

Main은 크게 Left Bar / Main / Right Bar로 구분했습니다.

Left BarMainRight Bar
카테고리카테고리별 라우터 이동검색창, 유저 정보

➕ 기능 및 특징

  • 실시간 업데이트

  • 유저 정보 확인
    • 로그아웃 가능

  • 트윗 작성
    • 별도의 버튼 추가 (홈이 아닌 다른 페이지에서 글을 쓰고자 할 때)
    • 이미지 추가 및 삭제 가능
    • 이모지 추가 (pc 버전에서만 지원하도록)
    • 수정/삭제

  • 반응형 액션 (답글, 리트윗, 좋아요, 북마크)

  • 검색창 및 팔로우 할 유저 추천 추가
    • 유저 팔로우, 언팔로우 가능
    • 새로고침 시 유저 목록 랜덤으로 노출

✨ 실시간 업데이트

  • Firebase에서는 데이터를 받아올 수 있는 api가 크게 getDocs()onSnapshot()가 있는데, 저는 실시간으로 값을 주고 받을 수 있는 onSnapShot()을 각 라우터에 사용하여 적용시켰습니다.
    • 데이터를 한 번만 받아올 수 있는 getDocs()를 사용하게 되면 무분별한 렌더링이 발생하지 않고, 서버에 과부하를 주지 않아 좋긴 하지만, 유저 정보·트윗·행동의 정보를 실시간으로 노출해야 하는 사이트의 특성상 onSnapShot()을 사용하게 되었습니다.

  • 프로필, 트윗을 클릭하면 해당 부분에 맞는 url로 이동하게 했습니다.
    • react-router-domuseHistory()를 사용했습니다.


✨ 유저 정보 확인

  • 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} />}
  	// ... 생략
  </>
)

└ progress bar

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 라이브러리를 사용했습니다.
    • 검색 목록에 노출되는 글은 클릭 시 해당 라우터로 이동하게 했습니다.
    • 검색창은 useLocationincludes() 메소드를 사용해 '탐색하기' 페이지에서만 사라지도록 했습니다. 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);

└ 팔로우 유저 (❗)

팔로우 기능은 custom Hooks로 따로 생성

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,
};

❗ 팔로우

  • 배열을 랜덤으로 바꿔주는 함수 실행 후 팔로우 버튼을 누를 때마다 리렌더링이 되어 유저 순서가 계속 바뀌는 이슈가 있었습니다. 전개 연산자로 복사를 하고 랜덤 함수를 실행하여 진행했으나, 깊은 복사가 되지 않고 원본 배열을 건드리게 되어 이슈가 생겼던 것 같습니다.
    • lodashcloneDeep()를 사용하여 해결할 수 있었고, 전개 연산자는 한 단계의 배열만 깊은 복사가 이루어진다는 것을 알게 되었습니다.


📌 참고

- 랜덤함수
- 이미지 압축

profile
의미있게 하기~.~

0개의 댓글