merge: codex/fix/all-active-sort

# Conflicts:
#	public/translations/ar/default.json
#	public/translations/bn/default.json
#	public/translations/cs/default.json
#	public/translations/da/default.json
#	public/translations/de/default.json
#	public/translations/el/default.json
#	public/translations/en/default.json
#	public/translations/es/default.json
#	public/translations/fa/default.json
#	public/translations/fi/default.json
#	public/translations/fil/default.json
#	public/translations/fr/default.json
#	public/translations/he/default.json
#	public/translations/hi/default.json
#	public/translations/hu/default.json
#	public/translations/id/default.json
#	public/translations/it/default.json
#	public/translations/ja/default.json
#	public/translations/ko/default.json
#	public/translations/mr/default.json
#	public/translations/nl/default.json
#	public/translations/no/default.json
#	public/translations/pl/default.json
#	public/translations/pt/default.json
#	public/translations/ro/default.json
#	public/translations/ru/default.json
#	public/translations/sq/default.json
#	public/translations/sv/default.json
#	public/translations/te/default.json
#	public/translations/th/default.json
#	public/translations/tr/default.json
#	public/translations/uk/default.json
#	public/translations/ur/default.json
#	public/translations/vi/default.json
#	public/translations/zh/default.json
This commit is contained in:
Tommaso Casaburi
2026-04-18 16:01:00 +07:00
51 changed files with 1296 additions and 119 deletions
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -184,7 +185,12 @@ const renderWithRoute = async (element: React.ReactElement, initialEntry: string
createElement(
Routes,
{},
createElement(Route, { path: '/all', element }),
createElement(Route, { path: '/all/catalog', element }),
createElement(Route, { path: '/subs', element }),
createElement(Route, { path: '/subs/catalog', element }),
createElement(Route, { path: '/mod', element }),
createElement(Route, { path: '/mod/catalog', element }),
createElement(Route, { path: '/mod/queue', element }),
createElement(Route, { path: '/:boardIdentifier/catalog', element }),
createElement(Route, { path: '/:boardIdentifier/archive', element }),
@@ -240,6 +246,8 @@ describe('BoardButtons', () => {
testState.subscribed = false;
testState.viewMode = 'compact';
useThreadLiveUpdatesStore.getState().resetState();
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
Object.defineProperty(globalThis, 'alert', {
configurable: true,
value: vi.fn(),
@@ -264,6 +272,8 @@ describe('BoardButtons', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
clearStableLastVisitTimeFilterName();
localStorage.clear();
});
it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, vote, subscribe, and archive flows', async () => {
@@ -300,7 +310,7 @@ describe('BoardButtons', () => {
it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => {
testState.filteredCount = 4;
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog');
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog?t=24h');
expect(container.textContent).toContain('filtered_threads');
expect(container.textContent).toContain('4');
@@ -309,21 +319,55 @@ describe('BoardButtons', () => {
expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search');
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
expect(selects).toHaveLength(4);
expect(selects).toHaveLength(5);
await changeSelect(selects[0]!, 'replyCount');
await changeSelect(selects[1]!, 'Large');
await changeSelect(selects[2]!, 'On');
await changeSelect(selects[3]!, 'nsfw');
await changeSelect(selects[4]!, '1w');
await clickButton('refresh');
expect(testState.setSortTypeMock).toHaveBeenCalledWith('replyCount');
expect(testState.setImageSizeMock).toHaveBeenCalledWith('Large');
expect(testState.setShowOPCommentMock).toHaveBeenCalledWith(true);
expect(testState.setFilterMock).toHaveBeenCalledWith('nsfw');
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=1w' });
expect(testState.resetMock).toHaveBeenCalledTimes(1);
});
it('preserves the current multiboard time filter when searching OPs', async () => {
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - 3 * 24 * 60 * 60 * 1000));
await renderWithRoute(createElement(DesktopBoardButtons), '/all?t=last');
const searchInput = container.querySelector<HTMLInputElement>('input[type="text"]');
expect(searchInput).toBeTruthy();
await act(async () => {
if (searchInput) {
searchInput.value = 'cats';
searchInput.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
}
});
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=last&q=cats' });
});
it('lets multiboard views switch back to the last-visit filter alias', async () => {
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - (2 * 24 * 60 * 60 + 1) * 1000));
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog?t=1w');
const timeFilterSelect = Array.from(container.querySelectorAll<HTMLSelectElement>('select')).at(-1);
expect(timeFilterSelect).toBeTruthy();
expect(Array.from(timeFilterSelect?.options || []).some((option) => option.value === 'last')).toBe(true);
await changeSelect(timeFilterSelect!, 'last');
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=last' });
});
it('renders thread actions and post stats, then requests refreshes, toggles auto updates, and scrolls to the bottom', async () => {
testState.commentsByCid = {
'comment-1': {
+112 -28
View File
@@ -17,11 +17,13 @@ import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useIsMobile from '../../hooks/use-is-mobile';
import useTimeFilter from '../../hooks/use-time-filter';
import CatalogFilters from '../catalog-filters';
import CatalogSearch from '../catalog-search';
import Tooltip from '../tooltip';
import { ModQueueButton } from '../../views/mod-queue/mod-queue';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getSearchWithTimeFilter, getTimeFilterOptionLabel } from '../../lib/utils/time-filter-utils';
import styles from './board-buttons.module.css';
import capitalize from 'lodash/capitalize';
@@ -35,14 +37,39 @@ interface BoardButtonsProps {
isTopbar?: boolean;
}
const getMultiboardPath = ({
isInAllView,
isInCatalogView,
isInSubscriptionsView,
isInModView,
}: Pick<BoardButtonsProps, 'isInAllView' | 'isInCatalogView' | 'isInSubscriptionsView' | 'isInModView'>) => {
if (isInAllView) {
return isInCatalogView ? '/all/catalog' : '/all';
}
if (isInSubscriptionsView) {
return isInCatalogView ? '/subs/catalog' : '/subs';
}
if (isInModView) {
return isInCatalogView ? '/mod/catalog' : '/mod';
}
return null;
};
export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => {
const { t } = useTranslation();
const location = useLocation();
const directories = useDirectories();
const { timeFilterValue } = useTimeFilter();
const createCatalogLink = () => {
if (isInAllView) return `/all/catalog`;
if (isInSubscriptionsView) return `/subs/catalog`;
if (isInModView) return `/mod/catalog`;
const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView: true, isInSubscriptionsView, isInModView });
if (multiboardPath) {
return {
pathname: multiboardPath,
search: getSearchWithTimeFilter(location.search, timeFilterValue),
};
}
let boardPath = '';
if (address) {
boardPath = getBoardPath(address, directories);
@@ -94,12 +121,12 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => {
export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView, isInModQueueView }: BoardButtonsProps) => {
const { t } = useTranslation();
const location = useLocation();
const params = useParams();
const directories = useDirectories();
const { timeFilterValue } = useTimeFilter();
const createReturnLink = () => {
if (isInAllView) return `/all`;
if (isInSubscriptionsView) return `/subs`;
if (isInModQueueView) {
// If in mod queue view, return to /mod or /:boardIdentifier
if (params?.boardIdentifier) {
@@ -107,7 +134,13 @@ export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isIn
}
return `/mod`;
}
if (isInModView) return `/mod`;
const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView: false, isInSubscriptionsView, isInModView });
if (multiboardPath) {
return {
pathname: multiboardPath,
search: getSearchWithTimeFilter(location.search, timeFilterValue, { removeKeys: ['q'] }),
};
}
let boardPath = '';
if (address) {
boardPath = getBoardPath(address, directories);
@@ -351,6 +384,42 @@ const ShowOPCommentOption = () => {
);
};
const TimeFilter = ({ isInAllView, isInCatalogView, isInSubscriptionsView, isInModView, isTopbar = false }: BoardButtonsProps) => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { lastVisitTimeFilterName, timeFilterValue, timeFilterValues } = useTimeFilter();
const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView, isInSubscriptionsView, isInModView });
if (!multiboardPath) {
return null;
}
const changeTimeFilter = (event: React.ChangeEvent<HTMLSelectElement>) => {
navigate({
pathname: multiboardPath,
search: getSearchWithTimeFilter(location.search, event.target.value),
});
};
return (
<>
{!isTopbar && (
<>
<span>{t('filter')}</span>:&nbsp;
</>
)}
<select onChange={changeTimeFilter} className={[styles.feedName, styles.menuItem, 'capitalize'].join(' ')} value={timeFilterValue}>
{timeFilterValues.map((value) => (
<option key={value} value={value}>
{getTimeFilterOptionLabel(value, lastVisitTimeFilterName)}
</option>
))}
</select>
</>
);
};
const AllFeedFilter = () => {
const { t } = useTranslation();
const { filter, setFilter } = useAllFeedFilterStore();
@@ -395,6 +464,7 @@ export const MobileBoardButtons = () => {
const { filteredCount, searchText } = useCatalogFiltersStore();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const showTimeFilter = isMultiboard;
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
const showBottomButton = !effectiveInfiniteScroll;
@@ -447,24 +517,24 @@ export const MobileBoardButtons = () => {
</span>
)
)}
{isInAllView && (
{(isInAllView || showTimeFilter || isInCatalogView) && (
<>
<hr />
<div className={styles.options}>
<AllFeedFilter />
</div>
</>
)}
{isInCatalogView && (
<>
<hr />
<div className={styles.options}>
<div>
<SortOptions /> <ImageSizeOptions />
</div>
<div className={styles.mobileCatalogOptionsPadding}>
<ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch />
</div>
{(isInAllView || showTimeFilter) && (
<div>
{isInAllView && <AllFeedFilter />}{' '}
{showTimeFilter && (
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
)}
</div>
)}
{isInCatalogView && (
<div className={styles.mobileCatalogOptionsPadding}>
<SortOptions /> <ImageSizeOptions />
<ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch />
</div>
)}
</div>
</>
)}
@@ -479,6 +549,16 @@ export const MobileBoardButtons = () => {
{!(isInAllView || isInSubscriptionsView || isInModView) && <SubscribeButton address={communityAddress} />}
{!(isInAllView || isInSubscriptionsView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />}
</div>
{showTimeFilter && (
<>
<hr />
<div className={styles.options}>
<div>
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
</div>
</div>
</>
)}
</>
)}
</div>
@@ -550,6 +630,7 @@ export const DesktopBoardButtons = () => {
const { filteredCount, searchText } = useCatalogFiltersStore();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const showTimeFilter = isMultiboard;
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
const showBottomButton = (isInCatalogView || isInPostView || isInPendingPostPage) && !effectiveInfiniteScroll;
@@ -653,6 +734,9 @@ export const DesktopBoardButtons = () => {
</>
)}
{isInAllView && <AllFeedFilter />}
{showTimeFilter && (
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
)}
{showVoteButton && (
<>
[<VoteButton />]
@@ -693,19 +777,19 @@ const SearchOPsBar = () => {
if (event.key === 'Enter') {
const searchQuery = (event.target as HTMLInputElement).value.trim();
if (searchQuery) {
let catalogUrl = '';
const params = new URLSearchParams(location.search);
params.set('q', searchQuery);
const search = `?${params.toString()}`;
if (isInAllView) {
catalogUrl = `/all/catalog?q=${encodeURIComponent(searchQuery)}`;
navigate({ pathname: '/all/catalog', search });
} else if (isInSubscriptionsView) {
catalogUrl = `/subs/catalog?q=${encodeURIComponent(searchQuery)}`;
navigate({ pathname: '/subs/catalog', search });
} else if (isInModView) {
catalogUrl = `/mod/catalog?q=${encodeURIComponent(searchQuery)}`;
navigate({ pathname: '/mod/catalog', search });
} else {
catalogUrl = `/${boardPath}/catalog?q=${encodeURIComponent(searchQuery)}`;
navigate(`/${boardPath}/catalog?q=${encodeURIComponent(searchQuery)}`);
}
navigate(catalogUrl);
}
}
};
@@ -2,6 +2,7 @@ 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 { TIME_FILTER_QUERY_PARAM } from '../../lib/utils/time-filter-utils';
import Board from '../../views/board';
import Catalog from '../../views/catalog';
import styles from './feed-cache-container.module.css';
@@ -9,20 +10,23 @@ 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 [pathname, search = ''] = key.split('?');
const segments = pathname.split('/').filter(Boolean);
const timeFilterName = new URLSearchParams(search).get(TIME_FILTER_QUERY_PARAM) || undefined;
const filteredSegments = segments.filter((s) => s !== 'catalog');
if (filteredSegments[0] === 'all') {
return { viewType: 'all' };
return { viewType: 'all', timeFilterName };
}
if (filteredSegments[0] === 'subs') {
return { viewType: 'subs' };
return { viewType: 'subs', timeFilterName };
}
if (filteredSegments[0] === 'mod') {
return { viewType: 'mod' };
return { viewType: 'mod', timeFilterName };
}
return {
@@ -42,9 +46,21 @@ const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => {
return (
<div className={isVisible ? styles.visible : styles.hidden}>
{feed.type === 'catalog' ? (
<Catalog feedCacheKey={feed.key} viewType={context.viewType} boardIdentifier={context.boardIdentifier} isVisible={isVisible} />
<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} isVisible={isVisible} />
<Board
feedCacheKey={feed.key}
viewType={context.viewType}
boardIdentifier={context.boardIdentifier}
timeFilterNameFromCache={context.timeFilterName}
isVisible={isVisible}
/>
)}
</div>
);
@@ -54,7 +70,7 @@ const FeedCacheContainer = () => {
const location = useLocation();
const { cachedFeeds, accessFeed } = useFeedCacheStore();
const currentFeedKey = getFeedCacheKey(location.pathname);
const currentFeedKey = getFeedCacheKey(location.pathname, location.search);
const isOnFeedRoute = isFeedRoute(location.pathname);
const feedType = getFeedType(location.pathname);