Skip to content

Commit

Permalink
Merge pull request #619 from depromeet/develop
Browse files Browse the repository at this point in the history
배포용 PR
  • Loading branch information
sumi-0011 committed Mar 18, 2024
2 parents f152e6f + 316daf8 commit 0c7cc1d
Show file tree
Hide file tree
Showing 10 changed files with 522 additions and 65 deletions.
68 changes: 48 additions & 20 deletions src/apis/feed.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
import getQueryKey from '@/apis/getQueryKey';
import apiInstance from '@/apis/instance.api';
import { type FeedBaseType, type FeedItemType } from '@/apis/schema/feed';
import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
import { useInfiniteQuery, type UseInfiniteQueryOptions, useQuery, type UseQueryOptions } from '@tanstack/react-query';

type GetFeedMeResponse = Array<FeedItemType>;
interface GetFeedListRequest {
visibility: FeedVisibilityType;
size: number;
lastId?: number;
}

type GetFeedListResponse = {
content: Array<FeedItemType>;
last: boolean;
};

type GetFeedByMemberIdResponse = Array<FeedBaseType>;

export type FeedVisibilityType = 'ALL' | 'FOLLOWER' | 'NONE';

export const FEED_API = {
getFeedMe: async (): Promise<GetFeedMeResponse> => {
const { data } = await apiInstance.get('/feed/me');
return data;
},
getFeed: async (memberId: number): Promise<GetFeedByMemberIdResponse> => {
const { data } = await apiInstance.get(`/feed/${memberId}`);
return data;
},
getFeedList: async (visibility: FeedVisibilityType): Promise<GetFeedMeResponse> => {
const { data } = await apiInstance.get('/feed', { params: { visibility } });
getFeedList: async (request: GetFeedListRequest): Promise<GetFeedListResponse> => {
const { data } = await apiInstance.get('/feed/me', {
params: request,
});
return data;
},
};

export const useFeedMe = (options?: UseQueryOptions<GetFeedMeResponse>) => {
return useQuery<GetFeedMeResponse>({
...options,
queryKey: getQueryKey('feedMe'),
queryFn: FEED_API.getFeedMe,
});
};

export const useFeedByMemberId = (memberId: number, options?: UseQueryOptions<GetFeedByMemberIdResponse>) => {
return useQuery<GetFeedByMemberIdResponse>({
...options,
Expand All @@ -40,10 +39,39 @@ export const useFeedByMemberId = (memberId: number, options?: UseQueryOptions<Ge
});
};

export const useGetFeedList = (visibility: FeedVisibilityType, options?: UseQueryOptions<GetFeedMeResponse>) => {
return useQuery<GetFeedMeResponse>({
export const useGetFeedList = (request: GetFeedListRequest, options?: UseQueryOptions<GetFeedListResponse>) => {
return useQuery<GetFeedListResponse>({
...options,
queryKey: getQueryKey('feedList', request),
queryFn: () => FEED_API.getFeedList(request),
});
};

export const useInfiniteFeedList = (
request: GetFeedListRequest,
options?: UseInfiniteQueryOptions<GetFeedListResponse>,
) => {
const queryKey = getQueryKey('feedList', request);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const queryFn = (param: any) => FEED_API.getFeedList({ ...request, lastId: param.pageParam });

const getNextPageParam = (lastPage: GetFeedListResponse): number | undefined => {
return lastPage.last ? undefined : lastPage.content[lastPage.content.length - 1].recordId;
};

return useInfiniteQuery({
...options,
queryKey: getQueryKey('feedList', { visibility }),
queryFn: () => FEED_API.getFeedList(visibility),
queryKey,
queryFn,
getNextPageParam,
initialPageParam: undefined,
select: (data) => {
const contents = data.pages.map((page) => page.content).flat();
return {
...data,
content: contents,
};
},
});
};
2 changes: 2 additions & 0 deletions src/apis/getQueryKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ type QueryList = {
};
feedList: {
visibility: string;
size: number;
lastId?: number;
};

// reaction
Expand Down
50 changes: 37 additions & 13 deletions src/app/feed/FeedList.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
'use client';

import { type FeedItemType } from '@/apis/schema/feed';
import { type FeedVisibilityType, useInfiniteFeedList } from '@/apis/feed';
import FeedItem, { FeedSkeletonItem } from '@/app/feed/FeedItem';
import Empty from '@/components/Empty/Empty';
import { ROUTER } from '@/constants/router';
import useIntersect from '@/hooks/useIntersect';
import { css } from '@styled-system/css';

function FeedList({ data }: { data?: Array<FeedItemType> }) {
if (!data)
return (
<ul className={feedListCss}>
<FeedSkeletonItem />
<FeedSkeletonItem />
</ul>
);
interface Props {
activeTab: FeedVisibilityType;
}

function FeedList({ activeTab }: Props) {
const { data, isLoading, hasNextPage, isFetching, fetchNextPage } = useInfiniteFeedList({
visibility: activeTab,
size: 5,
});

if (data.length === 0) {
const list = data?.content?.filter((feed) => feed.recordImageUrl); // 이미지 없는 경우가 있음. 나중에 리팩토링 + 서버와 이야기, FeedItem에 ErrorBoundary 적용해도 좋을 듯.

const targetRef = useIntersect(async (entry, observer) => {
observer.unobserve(entry.target); // TODO : 한번만 보여줄 지?
if (hasNextPage && !isFetching) fetchNextPage();
});

if (!data || isLoading) return <FeedListSkeleton />;

if (list?.length === 0) {
return (
<div className={emptyFeedCss}>
<Empty
Expand All @@ -32,15 +43,24 @@ function FeedList({ data }: { data?: Array<FeedItemType> }) {

return (
<ul className={feedListCss}>
{data.map((feed) => (
<FeedItem key={feed.recordId} {...feed} />
))}
{list?.map((feed) => <FeedItem key={feed.recordId} {...feed} />)}
<div className={observerCss} ref={targetRef} />
</ul>
);
}

export default FeedList;

function FeedListSkeleton() {
return (
<ul className={feedListCss}>
<FeedSkeletonItem />
<FeedSkeletonItem />
<FeedSkeletonItem />
</ul>
);
}

const emptyFeedCss = css({
display: 'flex',
justifyContent: 'center',
Expand All @@ -54,3 +74,7 @@ const feedListCss = css({
flexDirection: 'column',
gap: '32px',
});

const observerCss = css({
height: '1px',
});
41 changes: 24 additions & 17 deletions src/app/feed/FeedSection.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
'use client';

import { useEffect } from 'react';
import { type FeedVisibilityType, useGetFeedList } from '@/apis/feed';
import { type FeedVisibilityType } from '@/apis/feed';
import FeedList from '@/app/feed/FeedList';
import Tab from '@/components/Tab/Tab';
import { useTab } from '@/components/Tab/Tab.hooks';
import useShowGuide, { GUIDE_KEY } from '@/hooks/useShowGuide';
import { css } from '@/styled-system/css';
import { css, cx } from '@/styled-system/css';
import { fixedPositionCss } from '@/styles/position';

const FEED_TABS: { id: FeedVisibilityType; tabName: string }[] = [
{
Expand All @@ -22,26 +21,28 @@ const FEED_TABS: { id: FeedVisibilityType; tabName: string }[] = [
function FeedSection() {
const tabProps = useTab(FEED_TABS, 'FOLLOWER');

const { data, isLoading } = useGetFeedList(tabProps.activeTab as FeedVisibilityType);
const feeds = data?.filter((feed) => feed.recordImageUrl); // 이미지 없는 경우가 있음. 나중에 리팩토링 + 서버와 이야기, FeedItem에 ErrorBoundary 적용해도 좋을 듯.
// const { data, isLoading } = useGetFeedList(tabProps.activeTab as FeedVisibilityType);
// const feeds = data?.filter((feed) => feed.recordImageUrl); // 이미지 없는 경우가 있음. 나중에 리팩토링 + 서버와 이야기, FeedItem에 ErrorBoundary 적용해도 좋을 듯.

// NOTE: 초기에 팔로워의 피드가 없는 상태라면, 전체 피드로 변경
useEffect(() => {
if (!isLoading) {
if (feeds?.length === 0) {
tabProps.onTabClick(FEED_TABS[0]);
}
}
}, [isLoading]);
// // NOTE: 초기에 팔로워의 피드가 없는 상태라면, 전체 피드로 변경
// useEffect(() => {
// if (!isLoading) {
// if (feeds?.length === 0) {
// tabProps.onTabClick(FEED_TABS[0]);
// }
// }
// }, [isLoading]);

useShowGuide(GUIDE_KEY.ALL_FEED_OPEN, 'appBar');
// useShowGuide(GUIDE_KEY.ALL_FEED_OPEN, 'appBar');
// TODO : 팔로워 피드 없을 때 전체 피드 유도 가이드

return (
<div>
<div className={tabWrapperCss}>
<div className={cx(fixedPositionCss, tabWrapperCss)}>
<Tab {...tabProps} />
</div>
<FeedList data={feeds} />
<div className={blankCss} />
<FeedList activeTab={tabProps.activeTab as FeedVisibilityType} />
</div>
);
}
Expand All @@ -50,4 +51,10 @@ export default FeedSection;

const tabWrapperCss = css({
padding: '16px 16px 4px 16px',
backgroundColor: 'bg.surface2',
zIndex: 1,
});

const blankCss = css({
height: '50px',
});
1 change: 1 addition & 0 deletions src/app/record/[id]/detail/MissionRecordDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const missionHistorySectionCss = css({

const textSecondaryColorCss = css({
color: 'text.secondary',
whiteSpace: 'pre',
});

const textTertiaryColorCss = css({
Expand Down
42 changes: 30 additions & 12 deletions src/hooks/mission/stopwatch/useStopwatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,35 +42,44 @@ export default function useStopwatch(status: StepType, missionId: string, onNext
return () => clearInterval(timer);
}, [second, status]);

// init time setting 분리
const settingInitTime = () => {
const initSeconds = getProgressMissionTime(missionId);
/**
* @description 진행되고 있던 미션 시간 데이터를 세팅합니다.
* @param {string} currentMissionId 현재 미션 아이디
*/
const settingInitTime = (currentMissionId: string): void => {
const initSeconds = getProgressMissionTime(currentMissionId);

if (!initSeconds) return false;
if (initSeconds >= MAX_SECONDS) {
setSecond(MAX_SECONDS);
} else {
setSecond(initSeconds);
}
return true;
};

// 화면 visible 상태로 변경 시, 시간을 다시 세팅
useVisibilityStateVisible(() => {
setIsPending(true);
settingInitTime();
settingInitTime(missionId);
setIsPending(false);
});

useEffect(() => {
// 해당 미션을 이어 가는 경우. init time setting
const flag = settingInitTime();
setIsPending(false);
if (!flag) return;
// 진행되고 있던 미션이 있는지 확인합니다.
const flag = checkIsExistProgressMission(missionId);
if (!flag) {
setIsPending(false);
return;
}

// 진행되고 있던 미션이 있는 경우, 시간을 세팅합니다.
settingInitTime(missionId);

// 이전 상태가 있을 경우, 이전 상태로 이동합니다.
const prevStatus = getPrevProgressMissionStatus(missionId);
prevStatus && onNextStep?.(prevStatus); // 바로 재시작
}, []);
prevStatus && onNextStep?.(prevStatus);

setIsPending(false);
}, [missionId]);

return { minutes: formattedMinutes, seconds: formattedSeconds, stepper, isFinished, isPending };
}
Expand All @@ -80,3 +89,12 @@ const recordTenMinuteEvent = (missionId: string) => {
missionId,
});
};

/**
* @description 진행되고 있던 미션이 있는지 확인합니다.
* @param missionId 미션 아이디
* @returns {boolean} 진행되고 있던 미션이 있는지 여부
*/
const checkIsExistProgressMission = (missionId: string) => {
return Boolean(getProgressMissionTime(missionId));
};
42 changes: 42 additions & 0 deletions src/hooks/useIntersect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef } from 'react';

//https://tech.kakaoenterprise.com/149
interface IntersectionObserverInit {
/**
* target의 가시성을 확인할 때 사용되는 상위 속성 이름- null 입력 시, 기본값으로 브라우저의 Viewport가 설정됨
*/
root?: Element | Document | null;
/**
* root에 마진값을 주어 범위를 확장 가능
*/
rootMargin?: string;
/**
* 콜백이 실행되기 위해 target의 가시성이 얼마나 필요한지 백분율로 표시
*/
threshold?: number | number[];
}

type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;

const useIntersect = (onIntersect: IntersectHandler, options?: IntersectionObserverInit) => {
const ref = useRef<HTMLDivElement>(null);
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach((entry) => {
if (entry.isIntersecting) onIntersect(entry, observer);
});
},
[onIntersect],
);

useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(callback, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options, callback]);

return ref;
};

export default useIntersect;
10 changes: 10 additions & 0 deletions src/styles/position.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { css } from '@/styled-system/css';

export const fixedPositionCss = css({
position: 'fixed',
marginLeft: 'auto',
marginRight: 'auto',
left: 0,
right: 0,
maxWidth: '475px',
});
Loading

0 comments on commit 0c7cc1d

Please sign in to comment.