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
5 changes: 3 additions & 2 deletions src/components/common/Post/PostBody.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useRef, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import BookInfoCard from '../../feed/BookInfoCard';
import type { PostBodyProps } from '@/types/post';
import lookmore from '../../../assets/feed/lookmore.svg';
Expand All @@ -12,13 +13,13 @@ const PostBody = ({
feedId,
contentUrls = [],
}: PostBodyProps) => {
const navigate = useNavigate();
const hasImage = contentUrls.length > 0;
const contentRef = useRef<HTMLDivElement>(null);
const [isTruncated, setIsTruncated] = useState(false);

const handlePostClick = (feedId: number) => {
// 새 탭에서 피드 상세 페이지 열기
window.open(`/feed/${feedId}`, '_blank');
navigate(`/feed/${feedId}`);
};

useEffect(() => {
Expand Down
10 changes: 8 additions & 2 deletions src/components/common/Post/PostFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { postSaveFeed } from '@/api/feeds/postSave';
import { postFeedLike } from '@/api/feeds/postFeedLike';
import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick';
import { Container } from './PostFooter.styled';
import { useNavigate } from 'react-router-dom';

interface PostFooterProps {
likeCount: number;
Expand All @@ -33,6 +34,7 @@ const PostFooter = ({
isDetail = false,
onSaveToggle,
}: PostFooterProps) => {
const navigate = useNavigate();
const [liked, setLiked] = useState(isLiked);
const [likeCount, setLikeCount] = useState<number>(initialLikeCount);
const [saved, setSaved] = useState(isSaved);
Expand Down Expand Up @@ -106,14 +108,18 @@ const PostFooter = ({

const handleComment = () => {
if (isDetail) return;
window.open(`/feed/${feedId}`, '_blank');
navigate(`/feed/${feedId}`);
};

return (
<Container isDetail={isDetail}>
<div className="left">
<div className="count">
<img src={liked ? activeLike : like} onClick={handleLike} style={{ opacity: isLikeLoading ? 0.6 : 1 }} />
<img
src={liked ? activeLike : like}
onClick={handleLike}
style={{ opacity: isLikeLoading ? 0.6 : 1 }}
/>
<div>{likeCount}</div>
</div>
<div className="count comment">
Expand Down
87 changes: 87 additions & 0 deletions src/hooks/useFeedCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useState, useEffect, useRef } from 'react';
import type { PostData } from '@/types/post';

const FEED_CACHE_KEY = 'feed_page_cache';
const FEED_CACHE_TTL = 10 * 60 * 1000; // 10분

export interface FeedCache {
activeTab: string;
totalFeedPosts: PostData[];
myFeedPosts: PostData[];
totalNextCursor: string;
myNextCursor: string;
totalIsLast: boolean;
myIsLast: boolean;
scrollY: number;
timestamp: number;
}

type FeedCachePayload = Omit<FeedCache, 'scrollY' | 'timestamp'>;

function readCache(): FeedCache | null {
try {
const raw = sessionStorage.getItem(FEED_CACHE_KEY);
if (!raw) return null;
const cache = JSON.parse(raw) as FeedCache;
if (Date.now() - cache.timestamp < FEED_CACHE_TTL) return cache;
} catch {
// ignore
}
return null;
}

export function writeFeedCache(payload: FeedCachePayload): void {
const cache: FeedCache = {
...payload,
scrollY: window.scrollY,
timestamp: Date.now(),
};
try {
sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache));
} catch {
// ignore (storage quota exceeded or private browsing)
}
}

export function useFeedCache(options?: { disableRestore?: boolean }) {
const [initialCache] = useState<FeedCache | null>(readCache);
const disableRestoreRef = useRef(options?.disableRestore ?? false);

useEffect(() => {
if (disableRestoreRef.current || !initialCache) return;
const y = initialCache.scrollY;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.scrollTo(0, y);
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
let timeout: ReturnType<typeof setTimeout>;

const handleScroll = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
try {
const raw = sessionStorage.getItem(FEED_CACHE_KEY);
if (!raw) return;
const cache = JSON.parse(raw) as FeedCache;
cache.scrollY = window.scrollY;
sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache));
} catch {
// ignore
}
}, 100);
};

window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
clearTimeout(timeout);
};
}, []);

return { initialCache };
}
20 changes: 19 additions & 1 deletion src/hooks/useInifinieScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ interface UseInifinieScrollOptions<T> {
rootRef?: RefObject<HTMLElement | null>;
rootMargin?: string;
threshold?: number;
initialItems?: T[];
initialCursor?: string | null;
initialIsLast?: boolean;
}

export const useInifinieScroll = <T>({
Expand All @@ -25,6 +28,9 @@ export const useInifinieScroll = <T>({
rootRef,
rootMargin = '200px 0px',
threshold = 0,
initialItems,
initialCursor,
initialIsLast,
}: UseInifinieScrollOptions<T>) => {
const [items, setItems] = useState<T[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
Expand All @@ -36,6 +42,7 @@ export const useInifinieScroll = <T>({
const sentinelRef = useRef<HTMLDivElement | null>(null);
const isFetchingRef = useRef(false);
const fetchPageRef = useRef(fetchPage);
const initializedRef = useRef(false);

useEffect(() => {
fetchPageRef.current = fetchPage;
Expand Down Expand Up @@ -89,11 +96,21 @@ export const useInifinieScroll = <T>({

useEffect(() => {
if (!enabled) return;

if (!initializedRef.current && initialItems && initialItems.length > 0) {
initializedRef.current = true;
setItems(initialItems);
setNextCursor(initialCursor ?? null);
setIsLast(initialIsLast ?? false);
return;
}

initializedRef.current = true;
setItems([]);
setNextCursor(null);
setIsLast(false);
void loadFirstPage();
}, [enabled, reloadKey, loadFirstPage]);
}, [enabled, reloadKey, loadFirstPage]); // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
if (!enabled || !sentinelRef.current) return;
Expand All @@ -115,6 +132,7 @@ export const useInifinieScroll = <T>({
return {
items,
setItems,
nextCursor,
isLast,
isLoading,
isLoadingMore,
Expand Down
110 changes: 57 additions & 53 deletions src/pages/feed/Feed.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import NavBar from '../../components/common/NavBar';
import TabBar from '../../components/feed/TabBar';
import MyFeed from '../../components/feed/MyFeed';
import TotalFeed from '../../components/feed/TotalFeed';
import MainHeader from '@/components/common/MainHeader';
import { FeedPostSkeleton, OtherFeedSkeleton } from '@/shared/ui/Skeleton';
import LoadingSpinner from '../../components/common/LoadingSpinner';
import writefab from '../../assets/common/writefab.svg';
import { useNavigate, useLocation } from 'react-router-dom';
import { getTotalFeeds } from '@/api/feeds/getTotalFeed';
import { getMyFeeds } from '@/api/feeds/getMyFeed';
import { useSocialLoginToken } from '@/hooks/useSocialLoginToken';
import { useFeedCache, writeFeedCache } from '@/hooks/useFeedCache';
import { useInifinieScroll } from '@/hooks/useInifinieScroll';
import { Container, SkeletonWrapper } from './Feed.styled';
import type { PostData } from '@/types/post';
Expand All @@ -19,25 +21,22 @@ const tabs = ['피드', '내 피드'];
const Feed = () => {
const navigate = useNavigate();
const location = useLocation();
const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab;
const [activeTab, setActiveTab] = useState<string>(initialTabFromState ?? tabs[0]);

const { waitForToken } = useSocialLoginToken();

const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab;
const { initialCache } = useFeedCache({ disableRestore: !!initialTabFromState });
const isRestoringFromCache = initialCache !== null && !initialTabFromState;

useEffect(() => {
if (initialTabFromState) {
navigate('.', { replace: true });
}
if (initialTabFromState) navigate('.', { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleSearchButton = () => {
navigate('/feed/search');
};
const [activeTab, setActiveTab] = useState<string>(
initialTabFromState ?? (isRestoringFromCache ? initialCache!.activeTab : tabs[0]),
);

const handleNoticeButton = () => {
navigate('/notice');
};
const skipScrollResetRef = useRef(isRestoringFromCache);

const totalFeed = useInifinieScroll<PostData>({
enabled: activeTab === '피드',
Expand All @@ -56,6 +55,9 @@ const Feed = () => {
const newPosts = next.filter(post => !existingIds.has(post.feedId));
return [...prev, ...newPosts];
},
initialItems: isRestoringFromCache ? initialCache!.totalFeedPosts : undefined,
initialCursor: isRestoringFromCache ? initialCache!.totalNextCursor || null : undefined,
initialIsLast: isRestoringFromCache ? initialCache!.totalIsLast : undefined,
});

const myFeed = useInifinieScroll<PostData>({
Expand All @@ -70,46 +72,52 @@ const Feed = () => {
isLast: response.data.isLast,
};
},
initialItems: isRestoringFromCache ? initialCache!.myFeedPosts : undefined,
initialCursor: isRestoringFromCache ? initialCache!.myNextCursor || null : undefined,
initialIsLast: isRestoringFromCache ? initialCache!.myIsLast : undefined,
});

useEffect(() => {
if (skipScrollResetRef.current) {
skipScrollResetRef.current = false;
return;
}
window.scrollTo(0, 0);
}, [activeTab]);

const currentFeed = activeTab === '피드' ? totalFeed : myFeed;

useEffect(() => {
const loadFeedsWithToken = async () => {
await waitForToken();

setTabLoading(true);

try {
const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500));

if (activeTab === '피드') {
await Promise.all([loadTotalFeeds(), minLoadingTime]);
} else if (activeTab === '내 피드') {
await Promise.all([loadMyFeeds(), minLoadingTime]);
}
} finally {
setTabLoading(false);
setInitialLoading(false);
}
};

loadFeedsWithToken();
}, [activeTab, waitForToken, loadTotalFeeds, loadMyFeeds]);
const hasItems = totalFeed.items.length > 0 || myFeed.items.length > 0;
if (!hasItems) return;
writeFeedCache({
activeTab,
totalFeedPosts: totalFeed.items,
myFeedPosts: myFeed.items,
totalNextCursor: totalFeed.nextCursor ?? '',
myNextCursor: myFeed.nextCursor ?? '',
totalIsLast: totalFeed.isLast,
myIsLast: myFeed.isLast,
});
}, [
activeTab,
totalFeed.items,
myFeed.items,
totalFeed.nextCursor,
myFeed.nextCursor,
totalFeed.isLast,
myFeed.isLast,
]);

return (
<Container>
<MainHeader
type="home"
leftButtonClick={handleSearchButton}
rightButtonClick={handleNoticeButton}
leftButtonClick={() => navigate('/feed/search')}
rightButtonClick={() => navigate('/notice')}
/>
<TabBar tabs={tabs} activeTab={activeTab} onTabClick={setActiveTab} />
{initialLoading || tabLoading ? (
{currentFeed.isLoading ? (
activeTab === '내 피드' ? (
<OtherFeedSkeleton showFollowButton={false} paddingTop={136} />
) : (
Expand All @@ -122,23 +130,19 @@ const Feed = () => {
) : (
<>
{activeTab === '피드' ? (
<>
<TotalFeed
showHeader={true}
posts={totalFeed.items}
isMyFeed={false}
isLast={totalFeed.isLast}
/>
</>
<TotalFeed
showHeader={true}
posts={totalFeed.items}
isMyFeed={false}
isLast={totalFeed.isLast}
/>
) : (
<>
<MyFeed
showHeader={false}
posts={myFeed.items}
isMyFeed={true}
isLast={myFeed.isLast}
/>
</>
<MyFeed
showHeader={false}
posts={myFeed.items}
isMyFeed={true}
isLast={myFeed.isLast}
/>
)}
{!currentFeed.isLast && <div ref={currentFeed.sentinelRef} style={{ height: 40 }} />}
{currentFeed.isLoadingMore && <LoadingSpinner size="small" fullHeight={false} />}
Expand Down
Loading