mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf: implement LRU-cached persistent feed mounting to eliminate Virtuoso flash
Keep Board/Catalog feeds mounted outside React Router's control to prevent Virtuoso from remounting when navigating to/from post pages. The feed is hidden via CSS when viewing posts and shown again when returning, eliminating the visible flash/displacement that occurred during scroll restoration. - Add FeedCacheContainer to manage LRU cache of 2 feeds - Modify Board/Catalog to accept cache props and visibility state - Add route utilities for feed/post route detection
This commit is contained in:
+30
-27
@@ -9,10 +9,8 @@ import useSpecialThemeStore from './stores/use-special-theme-store';
|
|||||||
import useIsMobile from './hooks/use-is-mobile';
|
import useIsMobile from './hooks/use-is-mobile';
|
||||||
import useTheme from './hooks/use-theme';
|
import useTheme from './hooks/use-theme';
|
||||||
import { useDefaultSubplebbits } from './hooks/use-default-subplebbits';
|
import { useDefaultSubplebbits } from './hooks/use-default-subplebbits';
|
||||||
import { getSubplebbitAddress } from './lib/utils/route-utils';
|
import { getSubplebbitAddress, isPostRoute, isPendingPostRoute } from './lib/utils/route-utils';
|
||||||
import styles from './app.module.css';
|
import styles from './app.module.css';
|
||||||
import Board from './views/board';
|
|
||||||
import Catalog from './views/catalog';
|
|
||||||
import FAQ from './views/faq';
|
import FAQ from './views/faq';
|
||||||
import Home from './views/home';
|
import Home from './views/home';
|
||||||
import NotFound from './views/not-found';
|
import NotFound from './views/not-found';
|
||||||
@@ -22,6 +20,7 @@ import { DesktopBoardButtons, MobileBoardButtons } from './components/board-butt
|
|||||||
import BoardHeader from './components/board-header';
|
import BoardHeader from './components/board-header';
|
||||||
import ChallengeModal from './components/challenge-modal';
|
import ChallengeModal from './components/challenge-modal';
|
||||||
import CreateBoardModal from './components/create-board-modal';
|
import CreateBoardModal from './components/create-board-modal';
|
||||||
|
import FeedCacheContainer from './components/feed-cache-container';
|
||||||
import ReplyModal from './components/reply-modal';
|
import ReplyModal from './components/reply-modal';
|
||||||
import PostForm from './components/post-form';
|
import PostForm from './components/post-form';
|
||||||
import SubplebbitStats from './components/subplebbit-stats';
|
import SubplebbitStats from './components/subplebbit-stats';
|
||||||
@@ -43,6 +42,9 @@ const BoardLayout = () => {
|
|||||||
const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined });
|
const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined });
|
||||||
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
||||||
|
|
||||||
|
const isOnPostRoute = isPostRoute(location.pathname);
|
||||||
|
const isOnPendingPostRoute = isPendingPostRoute(location.pathname);
|
||||||
|
|
||||||
// Christmas theme
|
// Christmas theme
|
||||||
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +88,8 @@ const BoardLayout = () => {
|
|||||||
<DesktopBoardButtons />
|
<DesktopBoardButtons />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Outlet />
|
<FeedCacheContainer />
|
||||||
|
{(isOnPostRoute || isOnPendingPostRoute) && <Outlet />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -141,36 +144,36 @@ const App = () => (
|
|||||||
<Route path='/' element={<Home />} />
|
<Route path='/' element={<Home />} />
|
||||||
<Route path='/faq' element={<FAQ />} />
|
<Route path='/faq' element={<FAQ />} />
|
||||||
<Route element={<BoardLayout />}>
|
<Route element={<BoardLayout />}>
|
||||||
<Route path='/all/:timeFilterName?' element={<Board />} />
|
<Route path='/all/:timeFilterName?' element={null} />
|
||||||
<Route path='/all/:timeFilterName?/settings' element={<Board />} />
|
<Route path='/all/:timeFilterName?/settings' element={null} />
|
||||||
|
<Route path='/all/catalog/:timeFilterName?' element={null} />
|
||||||
|
<Route path='/all/catalog/:timeFilterName?/settings' element={null} />
|
||||||
|
|
||||||
|
<Route path='/subs/:timeFilterName?' element={null} />
|
||||||
|
<Route path='/subs/:timeFilterName?/settings' element={null} />
|
||||||
|
<Route path='/subs/catalog/:timeFilterName?' element={null} />
|
||||||
|
<Route path='/subs/catalog/:timeFilterName?/settings' element={null} />
|
||||||
|
|
||||||
|
<Route path='/mod/:timeFilterName?' element={null} />
|
||||||
|
<Route path='/mod/:timeFilterName?/settings' element={null} />
|
||||||
|
<Route path='/mod/catalog/:timeFilterName?' element={null} />
|
||||||
|
<Route path='/mod/catalog/:timeFilterName?/settings' element={null} />
|
||||||
|
|
||||||
|
<Route path='/:boardIdentifier' element={null} />
|
||||||
|
<Route path='/:boardIdentifier/settings' element={null} />
|
||||||
|
<Route path='/:boardIdentifier/catalog' element={null} />
|
||||||
|
<Route path='/:boardIdentifier/catalog/settings' element={null} />
|
||||||
|
|
||||||
<Route path='/all/description' element={<Post />} />
|
<Route path='/all/description' element={<Post />} />
|
||||||
<Route path='/all/catalog/:timeFilterName?' element={<Catalog />} />
|
|
||||||
<Route path='/all/catalog/:timeFilterName?/settings' element={<Catalog />} />
|
|
||||||
|
|
||||||
<Route path='/subs/:timeFilterName?' element={<Board />} />
|
|
||||||
<Route path='/subs/:timeFilterName?/settings' element={<Board />} />
|
|
||||||
<Route path='/subs/catalog/:timeFilterName?' element={<Catalog />} />
|
|
||||||
<Route path='/subs/catalog/:timeFilterName?/settings' element={<Catalog />} />
|
|
||||||
|
|
||||||
<Route path='/mod/:timeFilterName?' element={<Board />} />
|
|
||||||
<Route path='/mod/:timeFilterName?/settings' element={<Board />} />
|
|
||||||
<Route path='/mod/catalog/:timeFilterName?' element={<Catalog />} />
|
|
||||||
<Route path='/mod/catalog/:timeFilterName?/settings' element={<Catalog />} />
|
|
||||||
|
|
||||||
<Route path='/pending/:accountCommentIndex' element={<PendingPost />} />
|
|
||||||
<Route path='/pending/:accountCommentIndex/settings' element={<PendingPost />} />
|
|
||||||
|
|
||||||
<Route path='/:boardIdentifier' element={<Board />} />
|
|
||||||
<Route path='/:boardIdentifier/settings' element={<Board />} />
|
|
||||||
<Route path='/:boardIdentifier/catalog' element={<Catalog />} />
|
|
||||||
<Route path='/:boardIdentifier/catalog/settings' element={<Catalog />} />
|
|
||||||
|
|
||||||
<Route path='/:boardIdentifier/thread/:commentCid' element={<Post />} />
|
<Route path='/:boardIdentifier/thread/:commentCid' element={<Post />} />
|
||||||
<Route path='/:boardIdentifier/thread/:commentCid/settings' element={<Post />} />
|
<Route path='/:boardIdentifier/thread/:commentCid/settings' element={<Post />} />
|
||||||
<Route path='/:boardIdentifier/description' element={<Post />} />
|
<Route path='/:boardIdentifier/description' element={<Post />} />
|
||||||
<Route path='/:boardIdentifier/description/settings' element={<Post />} />
|
<Route path='/:boardIdentifier/description/settings' element={<Post />} />
|
||||||
<Route path='/:boardIdentifier/rules' element={<Post />} />
|
<Route path='/:boardIdentifier/rules' element={<Post />} />
|
||||||
<Route path='/:boardIdentifier/rules/settings' element={<Post />} />
|
<Route path='/:boardIdentifier/rules/settings' element={<Post />} />
|
||||||
|
|
||||||
|
<Route path='/pending/:accountCommentIndex' element={<PendingPost />} />
|
||||||
|
<Route path='/pending/:accountCommentIndex/settings' element={<PendingPost />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path='/not-found' element={<NotFound />} />
|
<Route path='/not-found' element={<NotFound />} />
|
||||||
<Route path='*' element={<NotFound />} />
|
<Route path='*' element={<NotFound />} />
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.visible {
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store';
|
||||||
|
import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils';
|
||||||
|
import Board from '../../views/board';
|
||||||
|
import Catalog from '../../views/catalog';
|
||||||
|
import styles from './feed-cache-container.module.css';
|
||||||
|
|
||||||
|
interface FeedContextFromKey {
|
||||||
|
viewType: 'all' | 'subs' | 'mod' | 'board';
|
||||||
|
boardIdentifier?: string;
|
||||||
|
timeFilterName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseFeedKey = (key: string): FeedContextFromKey => {
|
||||||
|
const segments = key.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
const filteredSegments = segments.filter((s) => s !== 'catalog');
|
||||||
|
if (filteredSegments[0] === 'all') {
|
||||||
|
return {
|
||||||
|
viewType: 'all',
|
||||||
|
timeFilterName: filteredSegments[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (filteredSegments[0] === 'subs') {
|
||||||
|
return {
|
||||||
|
viewType: 'subs',
|
||||||
|
timeFilterName: filteredSegments[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (filteredSegments[0] === 'mod') {
|
||||||
|
return {
|
||||||
|
viewType: 'mod',
|
||||||
|
timeFilterName: filteredSegments[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewType: 'board',
|
||||||
|
boardIdentifier: filteredSegments[0],
|
||||||
|
timeFilterName: filteredSegments[1],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CachedFeedWrapperProps {
|
||||||
|
feed: CachedFeed;
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => {
|
||||||
|
const context = parseFeedKey(feed.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isVisible ? styles.visible : styles.hidden}>
|
||||||
|
{feed.type === 'catalog' ? (
|
||||||
|
<Catalog
|
||||||
|
feedCacheKey={feed.key}
|
||||||
|
viewType={context.viewType}
|
||||||
|
boardIdentifier={context.boardIdentifier}
|
||||||
|
timeFilterNameFromCache={context.timeFilterName}
|
||||||
|
isVisible={isVisible}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Board
|
||||||
|
feedCacheKey={feed.key}
|
||||||
|
viewType={context.viewType}
|
||||||
|
boardIdentifier={context.boardIdentifier}
|
||||||
|
timeFilterNameFromCache={context.timeFilterName}
|
||||||
|
isVisible={isVisible}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeedCacheContainer = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const { cachedFeeds, accessFeed } = useFeedCacheStore();
|
||||||
|
|
||||||
|
const currentFeedKey = getFeedCacheKey(location.pathname);
|
||||||
|
const isOnFeedRoute = isFeedRoute(location.pathname);
|
||||||
|
const feedType = getFeedType(location.pathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOnFeedRoute && currentFeedKey && feedType) {
|
||||||
|
accessFeed(currentFeedKey, feedType);
|
||||||
|
}
|
||||||
|
}, [currentFeedKey, isOnFeedRoute, feedType, accessFeed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cachedFeeds.map((feed) => (
|
||||||
|
<CachedFeedWrapper key={feed.key} feed={feed} isVisible={isOnFeedRoute && feed.key === currentFeedKey} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeedCacheContainer;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './feed-cache-container';
|
||||||
@@ -100,3 +100,85 @@ export const isDirectoryBoard = (identifier: string, subplebbits: MultisubSubple
|
|||||||
const directoryToAddress = getDirectoryToAddressMap(subplebbits);
|
const directoryToAddress = getDirectoryToAddressMap(subplebbits);
|
||||||
return directoryToAddress.has(identifier);
|
return directoryToAddress.has(identifier);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isFeedRoute = (pathname: string): boolean => {
|
||||||
|
const normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
|
|
||||||
|
if (normalizedPath.includes('/thread/')) return false;
|
||||||
|
if (normalizedPath.endsWith('/description')) return false;
|
||||||
|
if (normalizedPath.endsWith('/rules')) return false;
|
||||||
|
if (normalizedPath.startsWith('/pending/')) return false;
|
||||||
|
|
||||||
|
const pathWithoutSettings = normalizedPath.replace(/\/settings$/, '');
|
||||||
|
|
||||||
|
if (pathWithoutSettings.startsWith('/all')) return true;
|
||||||
|
if (pathWithoutSettings.startsWith('/subs')) return true;
|
||||||
|
if (pathWithoutSettings.startsWith('/mod')) return true;
|
||||||
|
|
||||||
|
const segments = pathWithoutSettings.split('/').filter(Boolean);
|
||||||
|
if (segments.length >= 1) {
|
||||||
|
if (segments.length === 1) return true;
|
||||||
|
if (segments.length === 2 && segments[1] === 'catalog') return true;
|
||||||
|
if (segments.length === 2 && /^(1h|24h|1w|1m|1y|all)$/.test(segments[1])) return true;
|
||||||
|
if (segments.length === 3 && segments[1] === 'catalog' && /^(1h|24h|1w|1m|1y|all)$/.test(segments[2])) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPostRoute = (pathname: string): boolean => {
|
||||||
|
const normalizedPath = pathname.replace(/\/settings$/, '');
|
||||||
|
|
||||||
|
if (normalizedPath.includes('/thread/')) return true;
|
||||||
|
if (normalizedPath.endsWith('/description')) return true;
|
||||||
|
if (normalizedPath.endsWith('/rules')) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPendingPostRoute = (pathname: string): boolean => {
|
||||||
|
const normalizedPath = pathname.replace(/\/settings$/, '');
|
||||||
|
return normalizedPath.startsWith('/pending/');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFeedCacheKey = (pathname: string): string | null => {
|
||||||
|
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
|
normalizedPath = normalizedPath.replace(/\/settings$/, '');
|
||||||
|
|
||||||
|
if (normalizedPath.includes('/thread/')) {
|
||||||
|
const parts = normalizedPath.split('/thread/');
|
||||||
|
return parts[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPath.endsWith('/description') || normalizedPath.endsWith('/rules')) {
|
||||||
|
return normalizedPath.replace(/\/(description|rules)$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPath.startsWith('/pending/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFeedRoute(pathname)) {
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFeedType = (pathname: string): 'board' | 'catalog' | null => {
|
||||||
|
const normalizedPath = pathname.replace(/\/settings$/, '');
|
||||||
|
|
||||||
|
if (normalizedPath.includes('/catalog')) {
|
||||||
|
return 'catalog';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFeedRoute(pathname)) {
|
||||||
|
return 'board';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPostRoute(pathname)) {
|
||||||
|
return 'board';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface CachedFeed {
|
||||||
|
key: string;
|
||||||
|
type: 'board' | 'catalog';
|
||||||
|
lastAccessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedCacheState {
|
||||||
|
cachedFeeds: CachedFeed[];
|
||||||
|
maxCacheSize: number;
|
||||||
|
accessFeed: (key: string, type: 'board' | 'catalog') => void;
|
||||||
|
removeFeed: (key: string) => void;
|
||||||
|
isFeedCached: (key: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFeedCacheStore = create<FeedCacheState>((set, get) => ({
|
||||||
|
cachedFeeds: [],
|
||||||
|
maxCacheSize: 2,
|
||||||
|
|
||||||
|
accessFeed: (key: string, type: 'board' | 'catalog') => {
|
||||||
|
const { cachedFeeds, maxCacheSize } = get();
|
||||||
|
const now = Date.now();
|
||||||
|
const existingIndex = cachedFeeds.findIndex((feed) => feed.key === key);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
const updatedFeeds = [...cachedFeeds];
|
||||||
|
updatedFeeds[existingIndex] = { ...updatedFeeds[existingIndex], lastAccessed: now };
|
||||||
|
set({ cachedFeeds: updatedFeeds });
|
||||||
|
} else {
|
||||||
|
const newFeed: CachedFeed = { key, type, lastAccessed: now };
|
||||||
|
let updatedFeeds = [...cachedFeeds, newFeed];
|
||||||
|
|
||||||
|
if (updatedFeeds.length > maxCacheSize) {
|
||||||
|
updatedFeeds.sort((a, b) => a.lastAccessed - b.lastAccessed);
|
||||||
|
updatedFeeds = updatedFeeds.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ cachedFeeds: updatedFeeds });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFeed: (key: string) => {
|
||||||
|
const { cachedFeeds } = get();
|
||||||
|
set({ cachedFeeds: cachedFeeds.filter((feed) => feed.key !== key) });
|
||||||
|
},
|
||||||
|
|
||||||
|
isFeedCached: (key: string) => {
|
||||||
|
const { cachedFeeds } = get();
|
||||||
|
return cachedFeeds.some((feed) => feed.key === key);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useFeedCacheStore;
|
||||||
+50
-23
@@ -6,14 +6,14 @@ import { Trans, useTranslation } from 'react-i18next';
|
|||||||
import styles from './board.module.css';
|
import styles from './board.module.css';
|
||||||
import { shouldShowSnow } from '../../lib/snow';
|
import { shouldShowSnow } from '../../lib/snow';
|
||||||
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
||||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
import { useDefaultSubplebbitAddresses, useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
||||||
import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
|
|
||||||
import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
|
import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
|
||||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
import { useFeedStateString } from '../../hooks/use-state-string';
|
||||||
import useTimeFilter from '../../hooks/use-time-filter';
|
import useTimeFilter from '../../hooks/use-time-filter';
|
||||||
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
|
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||||
import useSortingStore from '../../stores/use-sorting-store';
|
import useSortingStore from '../../stores/use-sorting-store';
|
||||||
|
import { getSubplebbitAddress } from '../../lib/utils/route-utils';
|
||||||
import ErrorDisplay from '../../components/error-display/error-display';
|
import ErrorDisplay from '../../components/error-display/error-display';
|
||||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||||
import SubplebbitDescription from '../../components/subplebbit-description';
|
import SubplebbitDescription from '../../components/subplebbit-description';
|
||||||
@@ -33,21 +33,42 @@ const createThreadsWithoutImagesFilter = () => ({
|
|||||||
key: 'threads-with-images-only',
|
key: 'threads-with-images-only',
|
||||||
});
|
});
|
||||||
|
|
||||||
const Board = () => {
|
export interface BoardProps {
|
||||||
|
// Props from FeedCacheContainer for cached feeds
|
||||||
|
feedCacheKey?: string;
|
||||||
|
viewType?: 'all' | 'subs' | 'mod' | 'board';
|
||||||
|
boardIdentifier?: string;
|
||||||
|
timeFilterNameFromCache?: string;
|
||||||
|
isVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: BoardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const subplebbitAddress = useResolvedSubplebbitAddress();
|
const params = useParams();
|
||||||
const boardPath = useBoardPath(subplebbitAddress);
|
|
||||||
const { hideThreadsWithoutImages } = useInterfaceSettingsStore();
|
const { hideThreadsWithoutImages } = useInterfaceSettingsStore();
|
||||||
|
|
||||||
const isInAllView = isAllView(location.pathname);
|
// Use props from cache if provided, otherwise fall back to URL-derived values
|
||||||
|
const isInAllView = viewType ? viewType === 'all' : false;
|
||||||
|
const isInSubscriptionsView = viewType ? viewType === 'subs' : false;
|
||||||
|
const isInModView = viewType ? viewType === 'mod' : false;
|
||||||
|
|
||||||
|
// Resolve subplebbit address from cache props or URL
|
||||||
|
const defaultSubplebbits = useDefaultSubplebbits();
|
||||||
|
const resolvedAddressFromUrl = useResolvedSubplebbitAddress();
|
||||||
|
const subplebbitAddress = useMemo(() => {
|
||||||
|
if (boardIdentifierProp) {
|
||||||
|
return getSubplebbitAddress(boardIdentifierProp, defaultSubplebbits);
|
||||||
|
}
|
||||||
|
return resolvedAddressFromUrl;
|
||||||
|
}, [boardIdentifierProp, defaultSubplebbits, resolvedAddressFromUrl]);
|
||||||
|
|
||||||
|
const boardPath = useBoardPath(subplebbitAddress);
|
||||||
const defaultSubplebbitAddresses = useDefaultSubplebbitAddresses();
|
const defaultSubplebbitAddresses = useDefaultSubplebbitAddresses();
|
||||||
|
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const subscriptions = account?.subscriptions;
|
const subscriptions = account?.subscriptions;
|
||||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
|
||||||
|
|
||||||
const isInModView = isModView(location.pathname);
|
|
||||||
const { accountSubplebbits } = useAccountSubplebbits();
|
const { accountSubplebbits } = useAccountSubplebbits();
|
||||||
const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
|
const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
|
||||||
|
|
||||||
@@ -65,7 +86,9 @@ const Board = () => {
|
|||||||
}, [isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, defaultSubplebbitAddresses, subscriptions, accountSubplebbitAddresses]);
|
}, [isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, defaultSubplebbitAddresses, subscriptions, accountSubplebbitAddresses]);
|
||||||
|
|
||||||
const { sortType } = useSortingStore();
|
const { sortType } = useSortingStore();
|
||||||
const { timeFilterSeconds, timeFilterName } = useTimeFilter();
|
const { timeFilterSeconds, timeFilterName: timeFilterNameFromHook } = useTimeFilter();
|
||||||
|
// Use time filter from cache if provided
|
||||||
|
const timeFilterName = timeFilterNameFromCache || timeFilterNameFromHook;
|
||||||
|
|
||||||
const feedOptions = {
|
const feedOptions = {
|
||||||
subplebbitAddresses,
|
subplebbitAddresses,
|
||||||
@@ -82,8 +105,11 @@ const Board = () => {
|
|||||||
|
|
||||||
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResetFunction(reset);
|
// Only set reset function when this feed is visible
|
||||||
}, [reset, setResetFunction, feed]);
|
if (isVisible) {
|
||||||
|
setResetFunction(reset);
|
||||||
|
}
|
||||||
|
}, [reset, setResetFunction, feed, isVisible]);
|
||||||
|
|
||||||
// show account comments instantly in the feed once published (cid defined), instead of waiting for the feed to update
|
// show account comments instantly in the feed once published (cid defined), instead of waiting for the feed to update
|
||||||
const filteredComments = useMemo(
|
const filteredComments = useMemo(
|
||||||
@@ -175,7 +201,6 @@ const Board = () => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const params = useParams();
|
|
||||||
const currentTimeFilterName = params?.timeFilterName || timeFilterName;
|
const currentTimeFilterName = params?.timeFilterName || timeFilterName;
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
@@ -272,21 +297,23 @@ const Board = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// save the last Virtuoso state to restore it when navigating back
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
// include pathname in key so each board has its own scroll state
|
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}-${timeFilterSeconds}` : `${location.pathname}-${sortType}-${timeFilterSeconds}`;
|
||||||
const virtuosoStateKey = `${location.pathname}-${sortType}-${timeFilterSeconds}`;
|
|
||||||
const navigationType = useNavigationType();
|
const navigationType = useNavigationType();
|
||||||
|
|
||||||
// When entering a board via link (PUSH/REPLACE), force scroll to top to avoid inheriting prior view scroll.
|
const hasBeenVisibleRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (navigationType !== 'POP') {
|
if (isVisible && !hasBeenVisibleRef.current) {
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
hasBeenVisibleRef.current = true;
|
||||||
|
if (navigationType !== 'POP') {
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [navigationType, location.pathname]);
|
}, [isVisible, navigationType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// capture the key at effect creation time to prevent race conditions during navigation
|
if (!isVisible) return;
|
||||||
|
|
||||||
const currentKey = virtuosoStateKey;
|
const currentKey = virtuosoStateKey;
|
||||||
const setLastVirtuosoState = () => {
|
const setLastVirtuosoState = () => {
|
||||||
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
|
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
|
||||||
@@ -297,15 +324,15 @@ const Board = () => {
|
|||||||
};
|
};
|
||||||
window.addEventListener('scroll', setLastVirtuosoState);
|
window.addEventListener('scroll', setLastVirtuosoState);
|
||||||
return () => window.removeEventListener('scroll', setLastVirtuosoState);
|
return () => window.removeEventListener('scroll', setLastVirtuosoState);
|
||||||
}, [virtuosoStateKey]);
|
}, [virtuosoStateKey, isVisible]);
|
||||||
|
|
||||||
// only restore scroll state on back/forward navigation (POP), not when clicking links (PUSH)
|
|
||||||
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
const boardTitle = title ? title : shortAddress || subplebbitAddress;
|
const boardTitle = title ? title : shortAddress || subplebbitAddress;
|
||||||
document.title = boardTitle + ' - 5chan';
|
document.title = boardTitle + ' - 5chan';
|
||||||
}, [title, shortAddress, subplebbitAddress]);
|
}, [title, shortAddress, subplebbitAddress, isVisible]);
|
||||||
|
|
||||||
const shouldShowErrorToUser = error?.message && feed.length === 0;
|
const shouldShowErrorToUser = error?.message && feed.length === 0;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Trans, useTranslation } from 'react-i18next';
|
|||||||
import { Comment, useAccount, useFeed, useSubplebbit, useBlock, useAccountComments } from '@plebbit/plebbit-react-hooks';
|
import { Comment, useAccount, useFeed, useSubplebbit, useBlock, useAccountComments } from '@plebbit/plebbit-react-hooks';
|
||||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||||
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
||||||
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
|
||||||
import useCatalogFeedRows from '../../hooks/use-catalog-feed-rows';
|
import useCatalogFeedRows from '../../hooks/use-catalog-feed-rows';
|
||||||
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
||||||
import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
|
import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
|
||||||
@@ -16,6 +15,7 @@ import useFeedResetStore from '../../stores/use-feed-reset-store';
|
|||||||
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
|
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
|
||||||
import useSortingStore from '../../stores/use-sorting-store';
|
import useSortingStore from '../../stores/use-sorting-store';
|
||||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||||
|
import { getSubplebbitAddress } from '../../lib/utils/route-utils';
|
||||||
import CatalogRow from '../../components/catalog-row';
|
import CatalogRow from '../../components/catalog-row';
|
||||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||||
import styles from './catalog.module.css';
|
import styles from './catalog.module.css';
|
||||||
@@ -123,20 +123,40 @@ const createCombinedFilter = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const Catalog = () => {
|
export interface CatalogProps {
|
||||||
|
// Props from FeedCacheContainer for cached feeds
|
||||||
|
feedCacheKey?: string;
|
||||||
|
viewType?: 'all' | 'subs' | 'mod' | 'board';
|
||||||
|
boardIdentifier?: string;
|
||||||
|
timeFilterNameFromCache?: string;
|
||||||
|
isVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: CatalogProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const subplebbitAddress = useResolvedSubplebbitAddress();
|
const params = useParams();
|
||||||
const boardPath = useBoardPath(subplebbitAddress);
|
|
||||||
|
|
||||||
const isInAllView = isAllView(location.pathname);
|
// Use props from cache if provided, otherwise fall back to URL-derived values
|
||||||
|
const isInAllView = viewType ? viewType === 'all' : false;
|
||||||
|
const isInSubscriptionsView = viewType ? viewType === 'subs' : false;
|
||||||
|
|
||||||
|
// Resolve subplebbit address from cache props or URL
|
||||||
const defaultSubplebbits = useDefaultSubplebbits();
|
const defaultSubplebbits = useDefaultSubplebbits();
|
||||||
|
const resolvedAddressFromUrl = useResolvedSubplebbitAddress();
|
||||||
|
const subplebbitAddress = useMemo(() => {
|
||||||
|
if (boardIdentifierProp) {
|
||||||
|
return getSubplebbitAddress(boardIdentifierProp, defaultSubplebbits);
|
||||||
|
}
|
||||||
|
return resolvedAddressFromUrl;
|
||||||
|
}, [boardIdentifierProp, defaultSubplebbits, resolvedAddressFromUrl]);
|
||||||
|
|
||||||
|
const boardPath = useBoardPath(subplebbitAddress);
|
||||||
const { hideAdultBoards } = useInterfaceSettingsStore();
|
const { hideAdultBoards } = useInterfaceSettingsStore();
|
||||||
const { showTextOnlyThreads, filterItems, searchText, clearMatchedFilters } = useCatalogFiltersStore();
|
const { showTextOnlyThreads, filterItems, searchText, clearMatchedFilters } = useCatalogFiltersStore();
|
||||||
|
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const subscriptions = account?.subscriptions;
|
const subscriptions = account?.subscriptions;
|
||||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
|
||||||
|
|
||||||
const subplebbitAddresses = useMemo(() => {
|
const subplebbitAddresses = useMemo(() => {
|
||||||
const filteredDefaultSubplebbits = defaultSubplebbits
|
const filteredDefaultSubplebbits = defaultSubplebbits
|
||||||
@@ -166,8 +186,10 @@ const Catalog = () => {
|
|||||||
const columnCount = Math.floor(useWindowWidth() / columnWidth);
|
const columnCount = Math.floor(useWindowWidth() / columnWidth);
|
||||||
const postsPerPage = columnCount <= 2 ? 10 : columnCount === 3 ? 15 : columnCount === 4 ? 20 : 25;
|
const postsPerPage = columnCount <= 2 ? 10 : columnCount === 3 ? 15 : columnCount === 4 ? 20 : 25;
|
||||||
|
|
||||||
const { timeFilterSeconds, timeFilterName } = useTimeFilter();
|
const { timeFilterSeconds, timeFilterName: timeFilterNameFromHook } = useTimeFilter();
|
||||||
const { sortType } = useSortingStore();
|
const { sortType } = useSortingStore();
|
||||||
|
// Use time filter from cache if provided
|
||||||
|
const timeFilterName = timeFilterNameFromCache || timeFilterNameFromHook;
|
||||||
|
|
||||||
// Create a stable callback for filter matching
|
// Create a stable callback for filter matching
|
||||||
const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => {
|
const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => {
|
||||||
@@ -303,8 +325,11 @@ const Catalog = () => {
|
|||||||
|
|
||||||
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResetFunction(reset);
|
// Only set reset function when this feed is visible
|
||||||
}, [reset, setResetFunction]);
|
if (isVisible) {
|
||||||
|
setResetFunction(reset);
|
||||||
|
}
|
||||||
|
}, [reset, setResetFunction, isVisible]);
|
||||||
|
|
||||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||||
const { error, shortAddress, state, title } = subplebbit || {};
|
const { error, shortAddress, state, title } = subplebbit || {};
|
||||||
@@ -344,7 +369,6 @@ const Catalog = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const params = useParams<{ sortType?: string; timeFilterName?: string }>();
|
|
||||||
const currentTimeFilterName = params?.timeFilterName || timeFilterName;
|
const currentTimeFilterName = params?.timeFilterName || timeFilterName;
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
@@ -450,21 +474,23 @@ const Catalog = () => {
|
|||||||
|
|
||||||
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit);
|
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit);
|
||||||
|
|
||||||
// save the last Virtuoso state to restore it when navigating back
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
// include pathname in key so each board has its own scroll state
|
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}-${timeFilterSeconds}` : `${location.pathname}-${sortType}-${timeFilterSeconds}-catalog`;
|
||||||
const virtuosoStateKey = `${location.pathname}-${sortType}-${timeFilterSeconds}-catalog`;
|
|
||||||
const navigationType = useNavigationType();
|
const navigationType = useNavigationType();
|
||||||
|
|
||||||
// When entering a board via link (PUSH/REPLACE), force scroll to top to avoid inheriting prior view scroll.
|
const hasBeenVisibleRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (navigationType !== 'POP') {
|
if (isVisible && !hasBeenVisibleRef.current) {
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
hasBeenVisibleRef.current = true;
|
||||||
|
if (navigationType !== 'POP') {
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [navigationType, location.pathname]);
|
}, [isVisible, navigationType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// capture the key at effect creation time to prevent race conditions during navigation
|
if (!isVisible) return;
|
||||||
|
|
||||||
const currentKey = virtuosoStateKey;
|
const currentKey = virtuosoStateKey;
|
||||||
const setLastVirtuosoState = () =>
|
const setLastVirtuosoState = () =>
|
||||||
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
|
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
|
||||||
@@ -474,17 +500,17 @@ const Catalog = () => {
|
|||||||
});
|
});
|
||||||
window.addEventListener('scroll', setLastVirtuosoState);
|
window.addEventListener('scroll', setLastVirtuosoState);
|
||||||
return () => window.removeEventListener('scroll', setLastVirtuosoState);
|
return () => window.removeEventListener('scroll', setLastVirtuosoState);
|
||||||
}, [virtuosoStateKey]);
|
}, [virtuosoStateKey, isVisible]);
|
||||||
|
|
||||||
// only restore scroll state on back/forward navigation (POP), not when clicking links (PUSH)
|
|
||||||
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
let documentTitle = title ? title : shortAddress;
|
let documentTitle = title ? title : shortAddress;
|
||||||
if (isInAllView) documentTitle = t('all');
|
if (isInAllView) documentTitle = t('all');
|
||||||
else if (isInSubscriptionsView) documentTitle = t('subscriptions');
|
else if (isInSubscriptionsView) documentTitle = t('subscriptions');
|
||||||
document.title = documentTitle + ` - ${t('catalog')} - 5chan`;
|
document.title = documentTitle + ` - ${t('catalog')} - 5chan`;
|
||||||
}, [title, shortAddress, isInAllView, isInSubscriptionsView, t]);
|
}, [title, shortAddress, isInAllView, isInSubscriptionsView, t, isVisible]);
|
||||||
|
|
||||||
// Clear matched filters when component mounts or when subplebbit changes
|
// Clear matched filters when component mounts or when subplebbit changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user