Merge branch 'codex/fix/catalog-search-empty-state'

This commit is contained in:
Tommaso Casaburi
2026-05-19 15:38:52 +07:00
43 changed files with 229 additions and 110 deletions
+27 -29
View File
@@ -523,7 +523,6 @@ export const MobileBoardButtons = () => {
const resolvedAddress = useResolvedCommunityAddress();
const communityAddress = resolvedAddress || accountComment?.communityAddress;
const { filteredCount, searchText } = useCatalogFiltersStore();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const showTimeFilter = isMultiboard;
@@ -574,19 +573,7 @@ export const MobileBoardButtons = () => {
isInModView={isInModView}
isMobilePlacement={true}
/>
{searchText ? (
<span className={styles.filteredThreadsCount}>
{' '}
- {t('search_results_for')}: <strong>{searchText}</strong>
</span>
) : (
filteredCount > 0 && (
<span className={styles.filteredThreadsCount}>
{' '}
- {t('filtered_threads')}: <strong>{filteredCount}</strong>
</span>
)
)}
<CatalogSearchResultsLabel />
{(isInAllView || showTimeFilter || isInCatalogView) && (
<>
<hr />
@@ -683,6 +670,31 @@ export const PostPageStats = () => {
);
};
export const CatalogSearchResultsLabel = () => {
const { t } = useTranslation();
const { filteredCount, searchText } = useCatalogFiltersStore();
if (searchText) {
return (
<span className={styles.filteredThreadsCount}>
{' '}
{t('search_results_for')}: <strong>{searchText}</strong>
</span>
);
}
if (filteredCount > 0) {
return (
<span className={styles.filteredThreadsCount}>
{' '}
{t('filtered_threads')}: <strong>{filteredCount}</strong>
</span>
);
}
return null;
};
export const DesktopBoardButtons = () => {
const { t } = useTranslation();
const params = useParams();
@@ -698,7 +710,6 @@ export const DesktopBoardButtons = () => {
const isInModView = isModView(location.pathname);
const isInModQueueView = isModQueueView(location.pathname);
const { filteredCount, searchText } = useCatalogFiltersStore();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const showTimeFilter = isMultiboard;
@@ -794,20 +805,7 @@ export const DesktopBoardButtons = () => {
<ModQueueButton boardIdentifier={boardIdentifier} isMobile={false} />
</>
)}
{isInCatalogView && searchText ? (
<span className={styles.filteredThreadsCount}>
{' '}
- {t('search_results_for')}: <strong>{searchText}</strong>
</span>
) : (
isInCatalogView &&
filteredCount > 0 && (
<span className={styles.filteredThreadsCount}>
{' '}
- {t('filtered_threads')}: <strong>{filteredCount}</strong>
</span>
)
)}
{isInCatalogView && <CatalogSearchResultsLabel />}
<span className={styles.rightSideButtons}>
{isInCatalogView && (
<>
@@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CatalogFooterFirstRow,
CatalogFooterStyleRow,
PageFooterDesktop,
PageFooterMobile,
StyleOnlyFooterFirstRow,
@@ -51,6 +52,7 @@ vi.mock('../../style-selector/style-selector', () => ({
}));
vi.mock('../../board-buttons/board-buttons', () => ({
CatalogSearchResultsLabel: () => null,
AutoButton: () => createElement('button', { type: 'button' }, 'auto-button'),
CatalogButton: ({
address,
@@ -154,6 +156,7 @@ describe('footer', () => {
isInAllView: true,
communityAddress: 'music-posting.eth',
}),
createElement(CatalogFooterStyleRow),
createElement(ThreadFooterStyleRow),
createElement(PageFooterMobile, {
children: createElement('div', { 'data-testid': 'mobile-child' }, 'mobile-child'),
+4 -1
View File
@@ -4,6 +4,10 @@
padding-bottom: 24px;
}
.footerCatalog {
margin-top: 0;
}
.firstRow {
display: flex;
align-items: center;
@@ -76,7 +80,6 @@
}
.styleLabel {
font-size: 12px;
text-transform: capitalize;
}
+33 -11
View File
@@ -4,7 +4,16 @@ import { useComment } from '@bitsocial/bitsocial-react-hooks';
import BoardsBar from '../boards-bar';
import SiteLegalMeta from '../site-legal-meta';
import StyleSelector from '../style-selector/style-selector';
import { ReturnButton, CatalogButton, TopButton, UpdateButton, AutoButton, PostPageStats, RefreshButton } from '../board-buttons/board-buttons';
import {
CatalogSearchResultsLabel,
ReturnButton,
CatalogButton,
TopButton,
UpdateButton,
AutoButton,
PostPageStats,
RefreshButton,
} from '../board-buttons/board-buttons';
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
@@ -22,15 +31,17 @@ import styles from './footer.module.css';
interface PageFooterDesktopProps {
/** Mode-specific first row content (e.g. board pagination or thread controls) */
firstRow: React.ReactNode;
firstRow?: React.ReactNode;
/** Optional row between first row and BoardsBar (e.g. style selector on thread page) */
styleRow?: React.ReactNode;
/** Catalog pages omit the default footer top margin */
variant?: 'catalog';
}
export const PageFooterDesktop = ({ firstRow, styleRow }: PageFooterDesktopProps) => (
<footer className={styles.footer}>
export const PageFooterDesktop = ({ firstRow, styleRow, variant }: PageFooterDesktopProps) => (
<footer className={variant === 'catalog' ? `${styles.footer} ${styles.footerCatalog}` : styles.footer}>
<hr />
<div className={styles.firstRow}>{firstRow}</div>
{firstRow != null ? <div className={styles.firstRow}>{firstRow}</div> : null}
{styleRow != null ? <div className={styles.styleRow}>{styleRow}</div> : null}
<div className={styles.boardsBarRow}>
<BoardsBar />
@@ -60,7 +71,7 @@ export const StyleOnlyFooterFirstRow = () => {
/* -----------------------------------------------------------------------------
* CatalogFooterFirstRow
* Catalog footer first row: Return, Archive, Top, Refresh on left; Style selector on right.
* Catalog footer nav row: Return, Catalog, Top, Refresh, plus search/filter label.
* -------------------------------------------------------------------------- */
interface CatalogFooterFirstRowProps {
@@ -71,7 +82,6 @@ interface CatalogFooterFirstRowProps {
}
export const CatalogFooterFirstRow = ({ communityAddress, isInAllView = false, isInSubscriptionsView = false, isInModView = false }: CatalogFooterFirstRowProps) => {
const { t } = useTranslation();
return (
<div className={styles.footerRow}>
<div className={styles.footerLeft}>
@@ -87,15 +97,27 @@ export const CatalogFooterFirstRow = ({ communityAddress, isInAllView = false, i
<span>
[<RefreshButton />]
</span>
</div>
<div className={styles.footerRight}>
<span className={styles.styleLabel}>{t('style')}:</span>
<StyleSelector />
<CatalogSearchResultsLabel />
</div>
</div>
);
};
/* -----------------------------------------------------------------------------
* CatalogFooterStyleRow
* Catalog style selector on its own row (below nav, above BoardsBar).
* -------------------------------------------------------------------------- */
export const CatalogFooterStyleRow = () => {
const { t } = useTranslation();
return (
<span className={styles.styleRowContent}>
<span className={styles.styleLabel}>{t('style')}:</span>
<StyleSelector />
</span>
);
};
/* -----------------------------------------------------------------------------
* ThreadFooterStyleRow
* Style selector on its own row (thread page only, below first row, above BoardsBar).
+1
View File
@@ -3,6 +3,7 @@ export {
PageFooterMobile,
StyleOnlyFooterFirstRow,
CatalogFooterFirstRow,
CatalogFooterStyleRow,
ThreadFooterFirstRow,
ThreadFooterStyleRow,
ThreadFooterMobile,
+25 -2
View File
@@ -322,8 +322,15 @@ vi.mock('../../../components/catalog-row', () => ({
vi.mock('../../../components/footer', () => ({
CatalogFooterFirstRow: ({ communityAddress }: { communityAddress?: string }) =>
createElement('div', { 'data-testid': 'catalog-first-row' }, communityAddress || 'multi'),
PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-desktop' }, firstRow),
createElement(
'div',
{ 'data-testid': 'catalog-first-row' },
communityAddress || 'multi',
testState.searchText ? ` - search_results_for: ${testState.searchText}` : null,
),
CatalogFooterStyleRow: () => createElement('div', { 'data-testid': 'catalog-style-row' }, 'style'),
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow?: React.ReactNode; styleRow?: React.ReactNode }) =>
createElement('div', { 'data-testid': 'catalog-footer-desktop' }, firstRow, styleRow),
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-mobile' }, children),
}));
@@ -902,6 +909,22 @@ describe('Catalog', () => {
expect(container.querySelector('[data-testid="catalog-first-row"]')?.textContent).toBe('music-posting.eth');
});
it('shows centered nothing found when catalog search has no matches', async () => {
testState.feed = [{ cid: 'network-post', title: 'cats on stage', communityAddress: 'music-posting.eth' }];
testState.searchText = 'asddasd';
testState.hasMore = false;
await renderCatalog({ initialEntry: '/mu/catalog?q=asddasd', routePath: '/:boardIdentifier/catalog' });
expect(container.textContent).toContain('nothing_found');
expect(container.textContent).not.toContain('no_threads');
expect(container.querySelector('[role="status"]')?.className).toContain('searchNothingFound');
expect(container.querySelector('[role="status"]')?.textContent).toBe('nothing_found');
expect(container.querySelectorAll('[data-testid="catalog-row"]')).toHaveLength(0);
expect(container.textContent).toContain('search_results_for');
expect(container.textContent).toContain('asddasd');
});
it('queries scoped recent account posts and still applies local search filtering before injecting them', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feed = [{ cid: 'network-post', title: 'cats on stage', communityAddress: 'music-posting.eth' }];
+16
View File
@@ -3,6 +3,22 @@
color: var(--post-mobile-abbr-text-color);
}
.stateString {
padding: 15px 0 10px 5px;
color: var(--post-mobile-abbr-text-color);
}
.searchNothingFound {
color: red;
display: block;
margin: 0;
padding: 1em 0;
text-align: center;
font-size: x-large;
font-weight: bold;
line-height: normal;
}
.morePostsSuggestion {
padding-bottom: 5px;
}
+51 -33
View File
@@ -25,7 +25,7 @@ import useSortingStore from '../../stores/use-sorting-store';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import { getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath } from '../../lib/utils/route-utils';
import CatalogRow from '../../components/catalog-row';
import { CatalogFooterFirstRow, PageFooterDesktop, PageFooterMobile } from '../../components/footer';
import { CatalogFooterFirstRow, CatalogFooterStyleRow, PageFooterDesktop, PageFooterMobile } from '../../components/footer';
import { ReturnButton, ArchiveButton, TopButton, RefreshButton } from '../../components/board-buttons/board-buttons';
import mobileFooterStyles from '../../components/footer/footer.module.css';
import LoadingEllipsis from '../../components/loading-ellipsis';
@@ -131,6 +131,20 @@ const CatalogFooter = ({
// Separate component for the loading state when there's no feed
// This also calls useFeedStateString internally to isolate re-renders
interface CatalogSearchNothingFoundProps {
className?: string;
}
const CatalogSearchNothingFound = ({ className }: CatalogSearchNothingFoundProps) => {
const { t } = useTranslation();
return (
<div className={className ?? styles.searchNothingFound} role='status'>
{t('nothing_found')}
</div>
);
};
interface CatalogLoadingProps {
communityAddresses: string[];
hasMore: boolean;
@@ -138,9 +152,10 @@ interface CatalogLoadingProps {
state: string | undefined;
subscriptionsLength: number;
error: Error | undefined;
hasActiveSearch: boolean;
}
const CatalogLoading = ({ communityAddresses, hasMore, combinedFeedLength, state, subscriptionsLength, error }: CatalogLoadingProps) => {
const CatalogLoading = ({ communityAddresses, hasMore, combinedFeedLength, state, subscriptionsLength, error, hasActiveSearch }: CatalogLoadingProps) => {
const { t } = useTranslation();
const rawFeedStateString = useFeedStateString(communityAddresses);
@@ -153,7 +168,9 @@ const CatalogLoading = ({ communityAddresses, hasMore, combinedFeedLength, state
) : subscriptionsLength === 0 ? (
<span className='red'>{t('not_subscribed_to_any_board')}</span>
) : !hasMore && combinedFeedLength === 0 ? (
t('no_threads')
hasActiveSearch ? null : (
t('no_threads')
)
) : (
hasMore && <LoadingEllipsis string={loadingStateString} />
)}
@@ -587,6 +604,11 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const footerMoreThreadsSuggestion = showHiddenThreads ? null : moreThreadsSuggestion;
const footerShowLoadingEllipsis = showHiddenThreads ? isLoadingHiddenCatalogThreads : effectiveInfiniteScroll;
const catalogFooterFirstRow = (
<CatalogFooterFirstRow communityAddress={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
);
const catalogFooterStyleRow = <CatalogFooterStyleRow />;
// Memoize footer component to preserve identity across renders (Virtuoso optimization)
// Note: useFeedStateString is called inside CatalogFooter to isolate re-renders from backend state changes
const footerComponents = useMemo(
@@ -604,16 +626,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
/>
}
/>
<PageFooterDesktop variant='catalog' firstRow={catalogFooterFirstRow} styleRow={catalogFooterStyleRow} />
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
@@ -633,6 +646,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
footerMoreThreadsSuggestion,
moreThreadsSuggestionPathname,
expandSuggestionTimeWindow,
catalogFooterFirstRow,
catalogFooterStyleRow,
communityAddress,
isInAllView,
isInSubscriptionsView,
@@ -655,16 +670,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
/>
}
/>
<PageFooterDesktop variant='catalog' firstRow={catalogFooterFirstRow} styleRow={catalogFooterStyleRow} />
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
@@ -683,6 +689,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
footerMoreThreadsSuggestion,
moreThreadsSuggestionPathname,
expandSuggestionTimeWindow,
catalogFooterFirstRow,
catalogFooterStyleRow,
communityAddress,
isInAllView,
isInSubscriptionsView,
@@ -692,6 +700,9 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
],
);
const isFeedLoaded = feed.length > 0 || hiddenThreadsCount > 0 || state === 'failed';
const hasActiveSearch = searchText.trim().length > 0;
const showSearchNothingFound =
hasActiveSearch && !footerHasMore && footerCombinedFeedLength === 0 && state !== 'failed' && !(isInSubscriptionsView && (subscriptions?.length || 0) === 0);
// Process the feed to move "top" posts to the top (applied after display sort)
const processedFeed = useMemo(() => {
@@ -896,6 +907,21 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</>
)}
</>
) : showSearchNothingFound ? (
<>
<CatalogSearchNothingFound />
<hr />
{catalogFooterFirstRow}
<PageFooterDesktop variant='catalog' styleRow={catalogFooterStyleRow} />
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ArchiveButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<TopButton />
<RefreshButton />
</div>
</PageFooterMobile>
</>
) : (
<>
<div className={styles.footer}>
@@ -906,18 +932,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
state={state}
subscriptionsLength={isInSubscriptionsView ? subscriptions?.length || 0 : 1}
error={error}
hasActiveSearch={hasActiveSearch}
/>
</div>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
/>
}
/>
<PageFooterDesktop variant='catalog' firstRow={catalogFooterFirstRow} styleRow={catalogFooterStyleRow} />
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />