From 7a2a28be2377e14e43a0d51cef0ad1948edc43b1 Mon Sep 17 00:00:00 2001 From: Jiyoung Jung <72294509+Jungjjeong@users.noreply.github.com> Date: Fri, 30 Aug 2024 01:01:59 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20follow=20state=20sync=20hook,=20sto?= =?UTF-8?q?re,=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/friend/route.ts | 17 +++- hooks/apis/index.ts | 2 + hooks/apis/use-follow-mutate.ts | 29 +++++++ hooks/apis/use-following-state.ts | 128 ++++++++++++++++++++++++++++++ store/index.ts | 1 + store/member-following.ts | 71 +++++++++++++++++ 6 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 hooks/apis/use-follow-mutate.ts create mode 100644 hooks/apis/use-following-state.ts create mode 100644 store/member-following.ts diff --git a/app/api/friend/route.ts b/app/api/friend/route.ts index 8f5a1d0d..2d4a3740 100644 --- a/app/api/friend/route.ts +++ b/app/api/friend/route.ts @@ -2,13 +2,24 @@ import { NextRequest, NextResponse } from 'next/server'; import { fetchData } from '@/apis/fetch-data'; -export type followingProps = { +export type FollowRequestBody = { followingId: number; }; +export type FollowListRequestBody = { + friends: number[]; +}; + export async function PUT(request: NextRequest) { - const { followingId } = (await request.json()) as followingProps; - const data = await fetchData(`/friend`, 'PUT', { followingId }); + const body = (await request.json()) as Promise; + const data = await fetchData(`/friend`, 'PUT', body); + + return NextResponse.json(data); +} + +export async function POST(request: NextRequest) { + const body = (await request.json()) as Promise; + const data = await fetchData(`/friend`, 'POST', body); return NextResponse.json(data); } diff --git a/hooks/apis/index.ts b/hooks/apis/index.ts index cf01b756..0d073503 100644 --- a/hooks/apis/index.ts +++ b/hooks/apis/index.ts @@ -1 +1,3 @@ export * from './use-current-member-info'; +export * from './use-follow-mutate'; +export * from './use-following-state'; diff --git a/hooks/apis/use-follow-mutate.ts b/hooks/apis/use-follow-mutate.ts new file mode 100644 index 00000000..945c3e9e --- /dev/null +++ b/hooks/apis/use-follow-mutate.ts @@ -0,0 +1,29 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; + +export type RemoveCheer = { + reactionId: number; +}; + +const fetchFollow = async (followingId: number) => { + const res = await fetch(`/api/friend`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ followingId }), + }); + + return res.json(); +}; + +export const useFollowMutate = () => { + const mutate = useMutation({ + mutationFn: (followingId: number) => fetchFollow(followingId), + }); + + return { + ...mutate, + }; +}; diff --git a/hooks/apis/use-following-state.ts b/hooks/apis/use-following-state.ts new file mode 100644 index 00000000..6ca81231 --- /dev/null +++ b/hooks/apis/use-following-state.ts @@ -0,0 +1,128 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; + +import { Response } from '@/apis'; +import { useMemberFollowingStore } from '@/store'; + +import { useFollowMutate } from './use-follow-mutate'; + +export type FollowState = { + memberId: number; + isFollowing: boolean; +}; + +export type FollowStateResponse = Response<{ followingList: FollowState[] }>; + +const fetchFollowingList = async (friendsArr: number[]) => { + const res = await fetch(`/api/friend`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ friends: friendsArr }), + }); + + return res.json(); +}; + +export const useMemberFollowingState = () => { + const { + safeGet, + set: storeSet, + useMemberIsFollowing, + } = useMemberFollowingStore(); + const { mutate: debouncedFollowMutate } = useFollowMutate(); + + // NOTE: 단일 isFollowing state 조회 + const getMemberFollowingState = async (friendId: number) => { + const storedData = safeGet(friendId); + + if (!storedData._original) { + const { data } = (await fetchFollowingList([ + friendId, + ])) as FollowStateResponse; + + if (data?.followingList?.length) { + const isFollowing = data.followingList.find( + ({ memberId }) => memberId === friendId, + )?.isFollowing; + + if (isFollowing === undefined) { + return storedData; + } + const _original = { + isFollowing, + }; + const fetchingFollowingState = { + _original, + ..._original, + }; + + storeSet(friendId, fetchingFollowingState); + return fetchingFollowingState; + } + } + + return storedData; + }; + + // NOTE: 리스트 isFollowing State 조회 + const useSyncFollowingListState = (friendsArr: number[]) => { + const enabled = !!friendsArr.length; + const { data, refetch } = useQuery({ + queryKey: ['useFollowingState', friendsArr], + queryFn: () => fetchFollowingList(friendsArr), + enabled, + }); + const followingList = useMemo( + () => data?.data?.followingList ?? [], + [data], + ); + + useEffect(() => { + followingList.forEach(({ memberId, isFollowing }) => { + const fetchingFollowingState = { + isFollowing: isFollowing ?? false, + }; + const storeFollowingState = { + _original: fetchingFollowingState, + ...fetchingFollowingState, + }; + + storeSet(memberId, storeFollowingState); + }); + }, [followingList]); + + return { + data, + refetch: () => enabled && refetch(), + }; + }; + + // NOTE: toggle Follow State + const toggleFollow = async (friendId: number) => { + const currentFollowingState = await getMemberFollowingState(friendId); + + if (!currentFollowingState._original) { + console.warn(`friendId:${friendId} 인 following 여부 정보가 없습니다.`); + return; + } + + const nextFollowingState = { + _original: currentFollowingState._original, + isFollowing: !currentFollowingState.isFollowing, + }; + + storeSet(friendId, nextFollowingState); + debouncedFollowMutate(friendId); + }; + + return { + getMemberFollowingState, + useSyncFollowingListState, + useMemberIsFollowing, + toggleFollow, + }; +}; diff --git a/store/index.ts b/store/index.ts index 2bf6207d..c07ab6f6 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,6 +1,7 @@ export * from './bottom-sheet'; export * from './calendar'; export * from './dialog'; +export * from './member-following'; export * from './modal'; export * from './toast'; export * from './withdrawal-reason'; diff --git a/store/member-following.ts b/store/member-following.ts new file mode 100644 index 00000000..1ffb95a9 --- /dev/null +++ b/store/member-following.ts @@ -0,0 +1,71 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +type OmittedOriginal = Omit; + +export type MemberFollowingState = { + _original: OmittedOriginal | null; + isFollowing: boolean; +}; + +// member ID별 고유한 쿼리 키 +const getQueryKey = (memberId: number) => ['followingState', String(memberId)]; + +// initialFollowingState = false로 설정 +// _original : 서버에서 맨 처음 받아온 데이터 +const initialFollowingState: MemberFollowingState = { + _original: null, + isFollowing: false, +}; + +/** + * @description 멤버별 팔로잉/팔로우 여부 관리하는 store + */ +export const useMemberFollowingStore = () => { + const qc = useQueryClient(); + + // member ID에 대해 팔로잉 여부 데이터 set + const set = (memberId: number, isFollowingState: MemberFollowingState) => { + qc.setQueryData(getQueryKey(memberId), () => isFollowingState); + }; + + // member ID에 대해 팔로잉 여부 데이터 get + const get = (memberId: number) => + qc.getQueryData(getQueryKey(memberId)); + + // member ID에 대해 안전한 팔로잉 여부 데이터 get -> 없을 시에 initialFollowingState 반환 + const safeGet = (memberId: number) => { + const stored = get(memberId); + if (!stored) { + set(memberId, { ...initialFollowingState }); + return { ...initialFollowingState }; + } + return stored; + }; + + // 여러개의 member ID에 대한 팔로잉 여부 데이터 동기화 + const sync = (data: Map) => { + data.forEach((isFollowingState, memberId) => + set(memberId, isFollowingState), + ); + return data.size; + }; + + // member ID에 대한 팔로잉 여부 데이터를 가져오는 훅 + const useMemberIsFollowing = (memberId: number) => { + const { data } = useQuery({ + queryKey: getQueryKey(memberId), + queryFn: () => safeGet(memberId), + initialData: safeGet(memberId), + staleTime: Infinity, + }); + + return data; + }; + + return { + set, + safeGet, + sync, + useMemberIsFollowing, + }; +}; From cec73c794de2710220c442ac6282e2bd1659fbe4 Mon Sep 17 00:00:00 2001 From: Jiyoung Jung <72294509+Jungjjeong@users.noreply.github.com> Date: Fri, 30 Aug 2024 01:17:41 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/apis/use-follow-mutate.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/hooks/apis/use-follow-mutate.ts b/hooks/apis/use-follow-mutate.ts index 945c3e9e..6ea62095 100644 --- a/hooks/apis/use-follow-mutate.ts +++ b/hooks/apis/use-follow-mutate.ts @@ -2,10 +2,6 @@ import { useMutation } from '@tanstack/react-query'; -export type RemoveCheer = { - reactionId: number; -}; - const fetchFollow = async (followingId: number) => { const res = await fetch(`/api/friend`, { method: 'PUT', From 31f6c713d63420f2df9603b30ee1952b553e61c8 Mon Sep 17 00:00:00 2001 From: Jiyoung Jung <72294509+Jungjjeong@users.noreply.github.com> Date: Fri, 30 Aug 2024 01:21:20 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=8C=94=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=8B=B1=ED=81=AC,=20handle=20click=20fol?= =?UTF-8?q?low=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/profile/[id]/page.tsx | 17 +------ .../profile-list/profile-list-item.tsx | 49 ++++++++++++------- .../molecules/profile-list/profile-list.tsx | 17 ++++++- features/follow/apis/use-follower-list.tsx | 15 ++++++ features/follow/apis/use-following-list.tsx | 15 ++++++ .../apis/use-profile-search.tsx | 15 ++++++ features/profile/apis/fetch-following-data.ts | 10 ---- features/profile/hooks/use-following-data.tsx | 16 ------ features/profile/types/follow.ts | 3 -- features/profile/types/index.ts | 1 - 10 files changed, 94 insertions(+), 64 deletions(-) delete mode 100644 features/profile/apis/fetch-following-data.ts delete mode 100644 features/profile/hooks/use-following-data.tsx delete mode 100644 features/profile/types/follow.ts diff --git a/app/profile/[id]/page.tsx b/app/profile/[id]/page.tsx index d2a5639f..43525b32 100644 --- a/app/profile/[id]/page.tsx +++ b/app/profile/[id]/page.tsx @@ -10,7 +10,6 @@ import { SettingButton } from '@/components/molecules'; import { useProfileData } from '@/features/profile'; import { MyProfile } from '@/features/profile/components/organisms/my-page'; import { OtherPage } from '@/features/profile/components/organisms/other-page'; -import { useFollowingData } from '@/features/profile/hooks/use-following-data'; import { css } from '@/styled-system/css'; import { flex } from '@/styled-system/patterns'; @@ -27,25 +26,16 @@ export default function Profile({ params }: Mypage) { const isMyProfile = profileData?.isMyProfile; - const { - data: followingData, - isLoading: isFollowingLoading, - error: followingError, - } = useFollowingData(params.id, isMyProfile); - if (profileError) { return
멤버가 존재하지 않아요.
; } - if (isProfileLoading || isFollowingLoading) { + if (isProfileLoading) { return ; } if (!profileData) { return
Profile data is not available.
; } - if (followingError) { - return
Error fetching following data.
; - } return (
@@ -66,10 +56,7 @@ export default function Profile({ params }: Mypage) { - + )} diff --git a/components/molecules/profile-list/profile-list-item.tsx b/components/molecules/profile-list/profile-list-item.tsx index a9ba371c..1b9d6729 100644 --- a/components/molecules/profile-list/profile-list-item.tsx +++ b/components/molecules/profile-list/profile-list-item.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { Button } from '@/components/atoms'; +import { useMemberFollowingState } from '@/hooks'; import { css } from '@/styled-system/css'; import { flex } from '@/styled-system/patterns'; import { MemberProfile } from '@/types'; @@ -8,7 +9,7 @@ import { MemberProfile } from '@/types'; import { ProfileImage } from '../profile-image'; type FollowListItem = { - isFollow: boolean; + isMyProfile?: boolean; onClick?: () => void; onClickFollow?: () => void; } & MemberProfile; @@ -17,8 +18,15 @@ export const ProfileListItem = ({ nickname, introduction, profileImageUrl, - isFollow, + isMyProfile, }: FollowListItem) => { + const { useMemberIsFollowing, toggleFollow } = useMemberFollowingState(); + const { isFollowing } = useMemberIsFollowing(memberId); + + const handleClickFollow = () => { + void toggleFollow(memberId); + }; + return (
@@ -37,21 +45,28 @@ export const ProfileListItem = ({
- {isFollow ? ( -