Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions src/app/message/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';

import { FollowingContent } from '@/components/pages/message/message-following-content';
import { ModalProvider } from '@/components/ui';
import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list';

import FollowingPage from './page';

jest.mock('@/hooks/use-group/use-group-infinite-list', () => ({
useInfiniteScroll: jest.fn(),
}));
Expand All @@ -15,12 +15,18 @@ jest.mock('next/navigation', () => ({
}),
}));

jest.mock('js-cookie', () => ({
get: () => '1',
}));

describe('FollowingPage 테스트', () => {
let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

(useInfiniteScroll as jest.Mock).mockReturnValue({
items: [],
error: null,
Expand All @@ -31,11 +37,17 @@ describe('FollowingPage 테스트', () => {
});
});

afterEach(() => {
queryClient.clear();
});

test('팔로잉이 없을 경우 FollowingNone을 보여준다', async () => {
render(
<ModalProvider>
<FollowingPage />
</ModalProvider>,
<QueryClientProvider client={queryClient}>
<ModalProvider>
<FollowingContent initialUserId={1} />
</ModalProvider>
</QueryClientProvider>,
);

expect(await screen.findByText('아직 팔로우 한 사람이 없어요.')).toBeInTheDocument();
Expand Down
112 changes: 24 additions & 88 deletions src/app/message/page.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,41 @@
'use client';
import { cookies } from 'next/headers';

import { useSearchParams } from 'next/navigation';

import { useEffect, useState } from 'react';

import Cookies from 'js-cookie';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';

import { API } from '@/api';
import { ChatList } from '@/components/pages/chat';
import { FollowingList, FollowingNone, FollowingSearch } from '@/components/pages/message';
import { TabNavigation } from '@/components/shared';
import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list';
import { useIntersectionObserver } from '@/hooks/use-intersection-observer';
import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list';
import { FollowingContent } from '@/components/pages/message/message-following-content';

const SOCIAL_TABS = [
{ label: '팔로잉', value: 'following' },
{ label: '메세지', value: 'chat' },
];
const INITIAL_PAGE_SIZE = 10;

export default function FollowingPage() {
const [userId, setUserId] = useState(0);
const params = useSearchParams();
const tab = params.get('tab') || 'chat';
export default async function MessagePage() {
const cookieStore = await cookies();
const userId = Number(cookieStore.get('userId')?.value || 0);

useEffect(() => {
const id = Cookies.get('userId');
// eslint-disable-next-line react-hooks/set-state-in-effect
setUserId(Number(id));
}, []);
const queryClient = new QueryClient();

// 1. 무한 스크롤 훅 호출
const {
items: followers,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
completedMessage,
} = useInfiniteScroll({
queryFn: async ({ cursor, size }) => {
// 첫 페이지 우선 prefetch
await queryClient.prefetchInfiniteQuery({
queryKey: ['followers', userId],
queryFn: async () => {
return await API.followerService.getFollowers({
userId,
cursor,
size,
cursor: undefined,
size: INITIAL_PAGE_SIZE,
});
},
queryKey: ['followers', userId],
completedMessage: '모든 팔로잉을 불러왔습니다.',
enabled: !!userId,
});

// 2. IntersectionObserver로 스크롤 감지
const sentinelRef = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
initialPageParam: undefined,
getNextPageParam: (lastPage) => {
return lastPage.nextCursor ?? undefined;
},
enabled: hasNextPage && error === null,
threshold: INTERSECTION_OBSERVER_THRESHOLD,
pages: 1,
});

return (
<div className='min-h-screen bg-[#F1F5F9]'>
<TabNavigation basePath='/message' defaultValue='chat' tabs={SOCIAL_TABS} />

{tab === 'chat' && <ChatList />}
{tab === 'following' && (
<>
<FollowingSearch userId={userId} />
{!error && followers && followers.length > 0 ? (
<>
<FollowingList items={followers} />

{/* 3. Sentinel 요소 (필수!) */}
{hasNextPage && <div ref={sentinelRef} className='h-1' />}
// dehydrate로 직렬화
const dehydratedState = dehydrate(queryClient);

{/* 4. 다음 페이지 로딩 중 */}
{isFetchingNextPage && (
<div className='flex items-center justify-center p-4'>
<span className='text-gray-500'>더 불러오는 중...</span>
</div>
)}

{/* 5. 완료 메시지 */}
{!hasNextPage && (
<div className='flex items-center justify-center p-4'>
<span className='text-gray-500'>{completedMessage}</span>
</div>
)}
</>
) : (
!error && (
<div className='flex flex-1 items-center justify-center'>
<FollowingNone />
</div>
)
)}
</>
)}
</div>
return (
<HydrationBoundary state={dehydratedState}>
<FollowingContent initialUserId={userId} />
</HydrationBoundary>
);
}
98 changes: 98 additions & 0 deletions src/components/pages/message/message-following-content/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client';

import { useSearchParams } from 'next/navigation';

import { API } from '@/api';
import { ChatList } from '@/components/pages/chat';
import { FollowingList, FollowingNone, FollowingSearch } from '@/components/pages/message';
import { TabNavigation } from '@/components/shared';
import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list';
import { useIntersectionObserver } from '@/hooks/use-intersection-observer';
import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list';

const SOCIAL_TABS = [
{ label: '팔로잉', value: 'following' },
{ label: '메세지', value: 'chat' },
];

interface FollowingContentProps {
initialUserId: number;
}

export const FollowingContent = ({ initialUserId }: FollowingContentProps) => {
const params = useSearchParams();
const tab = params.get('tab') || 'chat';

// 1. 무한 스크롤 훅 호출 (서버에서 prefetch된 데이터가 자동으로 사용됨)
const {
items: followers,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
completedMessage,
} = useInfiniteScroll({
queryFn: async ({ cursor, size }) => {
return await API.followerService.getFollowers({
userId: initialUserId,
cursor,
size,
});
},
queryKey: ['followers', initialUserId],
completedMessage: '모든 팔로잉을 불러왔습니다.',
enabled: !!initialUserId,
});

// 2. IntersectionObserver로 스크롤 감지
const sentinelRef = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
enabled: hasNextPage && error === null,
threshold: INTERSECTION_OBSERVER_THRESHOLD,
});

return (
<div className='min-h-screen bg-[#F1F5F9]'>
<TabNavigation basePath='/message' defaultValue='chat' tabs={SOCIAL_TABS} />

{tab === 'chat' && <ChatList />}
{tab === 'following' && (
<>
<FollowingSearch userId={initialUserId} />
{!error && followers && followers.length > 0 ? (
<>
<FollowingList items={followers} />

{/* 3. Sentinel 요소 (필수!) */}
{hasNextPage && <div ref={sentinelRef} className='h-1' />}

{/* 4. 다음 페이지 로딩 중 */}
{isFetchingNextPage && (
<div className='flex items-center justify-center p-4'>
<span className='text-gray-500'>더 불러오는 중...</span>
</div>
)}

{/* 5. 완료 메시지 */}
{!hasNextPage && (
<div className='flex items-center justify-center p-4'>
<span className='text-gray-500'>{completedMessage}</span>
</div>
)}
</>
) : (
!error && (
<div className='flex flex-1 items-center justify-center'>
<FollowingNone />
</div>
)
)}
</>
)}
</div>
);
};
3 changes: 3 additions & 0 deletions src/components/pages/message/message-following-none/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import Image from 'next/image';

export const FollowingNone = () => {
return (
<div className='mt-60 flex flex-col items-center gap-2 text-gray-600'>
<Image width={140} alt='Empty Data' height={140} src='/images/image-empty.png' />
<span>아직 팔로우 한 사람이 없어요.</span>
<span>마음에 드는 유저를 팔로우 해보세요!</span>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const FollowingSearch = ({ userId }: { userId: number }) => {
const { open } = useModal();
return (
<div
className='flex items-center gap-5 px-5 py-4 transition-all hover:cursor-pointer hover:opacity-80'
className='bg-mono-white mb-2 flex items-center gap-5 px-5 py-4 transition-all hover:cursor-pointer hover:opacity-80'
onClick={() => open(<FollowingModal userId={userId} />)}
>
<div className='rounded-full border-2 border-dashed border-gray-400 bg-gray-100 p-2'>
Expand Down