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
+21 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import {
areSameBoardAddress,
extractDirectoryFromTitle,
@@ -20,6 +20,7 @@ import {
normalizeMultiboardFeedPath,
stripPageFromFeedPath,
} from '../route-utils';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY, touchLastVisitTimestamp } from '../time-filter-utils';
const communities = [
{ address: 'business.eth', title: '/biz/ - Business & Finance' },
@@ -32,6 +33,11 @@ const communities = [
{ address: 'random.eth', directoryCode: 'b', title: 'Random' },
];
beforeEach(() => {
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
});
describe('directory mapping helpers', () => {
it('extracts short codes from titled directories', () => {
expect(extractDirectoryFromTitle('/biz/ - Business & Finance')).toBe('biz');
@@ -189,9 +195,23 @@ describe('feed cache helpers', () => {
expect(getFeedCacheKey('/biz/3/settings')).toBe('/biz');
expect(getFeedCacheKey('/biz/catalog/4')).toBe('/biz/catalog');
expect(getFeedCacheKey('/biz/thread/abc')).toBe('/biz');
expect(getFeedCacheKey('/all')).toBe('/all?t=24h');
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=24h');
expect(getFeedCacheKey('/all/catalog', '?t=1w&q=cats')).toBe('/all/catalog?t=1w');
expect(getFeedCacheKey('/biz/archive')).toBeNull();
});
it('keeps the last-visit alias stable after the current visit starts updating storage', () => {
const justOverTwoDaysAgo = Date.now() - (2 * 24 * 60 * 60 * 1000 + 1000);
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(justOverTwoDaysAgo));
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=3d');
touchLastVisitTimestamp();
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=3d');
});
it('returns null cache keys for non-feed routes', () => {
expect(getFeedCacheKey('/pending/3')).toBeNull();
expect(getFeedCacheKey('/biz/mod/queue')).toBeNull();
+8 -2
View File
@@ -1,4 +1,5 @@
import { DirectoryCommunity, findDirectoryByAddress, normalizeBoardAddress } from '../../hooks/use-directories';
import { getEffectiveTimeFilterName, getSearchWithTimeFilter } from './time-filter-utils';
/**
* Extract directory short code from title (e.g., "/biz/ - Business & Finance" -> "biz")
@@ -256,7 +257,7 @@ export const getPageFromFeedPath = (pathname: string): number => {
return 1;
};
export const getFeedCacheKey = (pathname: string): string | null => {
export const getFeedCacheKey = (pathname: string, search = ''): string | null => {
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
normalizedPath = normalizedPath.replace(/\/settings$/, '');
@@ -274,7 +275,12 @@ export const getFeedCacheKey = (pathname: string): string | null => {
}
if (isFeedRoute(pathname)) {
return stripPageFromFeedPath(normalizedPath);
const strippedFeedPath = stripPageFromFeedPath(normalizedPath);
if (isMultiboardFeedPath(strippedFeedPath)) {
const timeFilterName = getEffectiveTimeFilterName(search);
return `${strippedFeedPath}${getSearchWithTimeFilter('', timeFilterName)}`;
}
return strippedFeedPath;
}
return null;
+249
View File
@@ -0,0 +1,249 @@
const DAY_IN_SECONDS = 24 * 60 * 60;
const LAST_VISIT_STORAGE_KEY = '5chanLastVisitTimestamp';
const LAST_VISIT_TIME_FILTER_VALUE = 'last';
const TIME_FILTER_QUERY_PARAM = 't';
const FALLBACK_TIME_FILTER_NAME = '24h';
const WEEK_IN_SECONDS = 7 * DAY_IN_SECONDS;
const MONTH_IN_SECONDS = 30 * DAY_IN_SECONDS;
const YEAR_IN_SECONDS = 365 * DAY_IN_SECONDS;
declare global {
interface Window {
_5chanLastVisitTimeFilterSnapshot?: string;
}
}
const presetTimeFilterSeconds: Record<string, number | undefined> = {
'1h': 60 * 60,
'12h': 12 * 60 * 60,
'24h': DAY_IN_SECONDS,
'48h': 2 * DAY_IN_SECONDS,
'1w': WEEK_IN_SECONDS,
'1m': MONTH_IN_SECONDS,
'1y': YEAR_IN_SECONDS,
all: undefined,
};
const timeFilterPresetNames = ['1h', '12h', '24h', '48h', '1w', '1m', '1y', 'all'] as const;
export { LAST_VISIT_STORAGE_KEY, TIME_FILTER_QUERY_PARAM };
const convertDynamicTimeFilterNameToSeconds = (timeFilterName: string): number | undefined => {
const match = timeFilterName.match(/^(\d+)([hdwmy])$/);
if (!match) {
return undefined;
}
const [, rawValue, unit] = match;
const value = Number.parseInt(rawValue, 10);
if (!Number.isFinite(value) || value <= 0) {
return undefined;
}
switch (unit) {
case 'h':
return value * 60 * 60;
case 'd':
return value * DAY_IN_SECONDS;
case 'w':
return value * 7 * DAY_IN_SECONDS;
case 'm':
return value * MONTH_IN_SECONDS;
case 'y':
return value * YEAR_IN_SECONDS;
default:
return undefined;
}
};
const getTimeFilterSeconds = (timeFilterName: string | null | undefined): number | undefined => {
if (!timeFilterName || timeFilterName === 'all') {
return undefined;
}
if (timeFilterName in presetTimeFilterSeconds) {
return presetTimeFilterSeconds[timeFilterName as keyof typeof presetTimeFilterSeconds];
}
return convertDynamicTimeFilterNameToSeconds(timeFilterName);
};
const isValidTimeFilterName = (timeFilterName: string | null | undefined): timeFilterName is string =>
timeFilterName === 'all' || typeof getTimeFilterSeconds(timeFilterName) === 'number';
const readLastVisitTimestamp = (): number | null => {
if (typeof window === 'undefined') {
return null;
}
const storedValue = window.localStorage.getItem(LAST_VISIT_STORAGE_KEY);
if (!storedValue) {
return null;
}
const parsedValue = Number.parseInt(storedValue, 10);
return Number.isFinite(parsedValue) ? parsedValue : null;
};
export const touchLastVisitTimestamp = (timestamp = Date.now()) => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(timestamp));
};
const getLastVisitTimeFilterName = (now = Date.now(), lastVisitTimestamp = readLastVisitTimestamp()): string => {
const secondsSinceLastVisit = lastVisitTimestamp ? (now - lastVisitTimestamp) / 1000 : Number.POSITIVE_INFINITY;
if (secondsSinceLastVisit > 30 * DAY_IN_SECONDS) {
return '1m';
}
if (secondsSinceLastVisit > 7 * DAY_IN_SECONDS) {
return `${Math.ceil(secondsSinceLastVisit / (7 * DAY_IN_SECONDS))}w`;
}
if (secondsSinceLastVisit > DAY_IN_SECONDS) {
return `${Math.ceil(secondsSinceLastVisit / DAY_IN_SECONDS)}d`;
}
return FALLBACK_TIME_FILTER_NAME;
};
export const getStableLastVisitTimeFilterName = (): string => {
if (typeof window === 'undefined') {
return getLastVisitTimeFilterName();
}
if (!window._5chanLastVisitTimeFilterSnapshot) {
window._5chanLastVisitTimeFilterSnapshot = getLastVisitTimeFilterName();
}
return window._5chanLastVisitTimeFilterSnapshot;
};
export const clearStableLastVisitTimeFilterName = () => {
if (typeof window === 'undefined') {
return;
}
delete window._5chanLastVisitTimeFilterSnapshot;
};
const getExplicitTimeFilterValueFromSearch = (search: string): string | undefined => {
const timeFilterValue = new URLSearchParams(search).get(TIME_FILTER_QUERY_PARAM);
if (timeFilterValue === LAST_VISIT_TIME_FILTER_VALUE) {
return timeFilterValue;
}
return isValidTimeFilterName(timeFilterValue) ? timeFilterValue : undefined;
};
export const getEffectiveTimeFilterName = (search: string, fallbackTimeFilterName = getStableLastVisitTimeFilterName()): string => {
const explicitTimeFilterValue = getExplicitTimeFilterValueFromSearch(search);
if (explicitTimeFilterValue === LAST_VISIT_TIME_FILTER_VALUE) {
return fallbackTimeFilterName;
}
return explicitTimeFilterValue ?? fallbackTimeFilterName;
};
export const getSelectedTimeFilterValue = (search: string): string => getExplicitTimeFilterValueFromSearch(search) ?? LAST_VISIT_TIME_FILTER_VALUE;
export const getTimeFilterOptionValues = (lastVisitTimeFilterName: string): string[] => {
const optionValues = [LAST_VISIT_TIME_FILTER_VALUE, ...timeFilterPresetNames];
const seenLabels = new Set<string>();
return optionValues.filter((value) => {
const label = value === LAST_VISIT_TIME_FILTER_VALUE ? lastVisitTimeFilterName : value;
if (seenLabels.has(label)) {
return false;
}
seenLabels.add(label);
return true;
});
};
export const getTimeFilterOptionLabel = (value: string, lastVisitTimeFilterName: string): string =>
value === LAST_VISIT_TIME_FILTER_VALUE ? lastVisitTimeFilterName : value;
export const getEffectiveTimeFilterSeconds = (search: string, fallbackTimeFilterName = getStableLastVisitTimeFilterName()): number | undefined => {
const effectiveTimeFilterName = getEffectiveTimeFilterName(search, fallbackTimeFilterName);
const effectiveTimeFilterSeconds = getTimeFilterSeconds(effectiveTimeFilterName);
if (effectiveTimeFilterName === 'all') {
return undefined;
}
return effectiveTimeFilterSeconds ?? presetTimeFilterSeconds[FALLBACK_TIME_FILTER_NAME];
};
export const getSearchWithTimeFilter = (
search: string,
timeFilterName: string | undefined,
options?: {
removeKeys?: string[];
},
): string => {
const params = new URLSearchParams(search);
for (const key of options?.removeKeys || []) {
params.delete(key);
}
if (timeFilterName) {
params.set(TIME_FILTER_QUERY_PARAM, timeFilterName);
} else {
params.delete(TIME_FILTER_QUERY_PARAM);
}
const nextSearch = params.toString();
return nextSearch ? `?${nextSearch}` : '';
};
export type TimeFilterSuggestion =
| {
i18nKey: 'more_threads_last_week';
timeFilterName: '1w';
}
| {
i18nKey: 'more_threads_last_month';
timeFilterName: '1m';
}
| {
i18nKey: 'more_threads_last_year';
timeFilterName: '1y';
};
export const getTimeFilterSuggestion = (
currentFeedLength: number,
weeklyFeedLength: number,
monthlyFeedLength: number,
yearlyFeedLength: number,
currentTimeFilterSeconds?: number,
): TimeFilterSuggestion | null => {
if (typeof currentTimeFilterSeconds !== 'number') {
return null;
}
if (currentTimeFilterSeconds < WEEK_IN_SECONDS && weeklyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_week',
timeFilterName: '1w',
};
}
if (currentTimeFilterSeconds < MONTH_IN_SECONDS && monthlyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_month',
timeFilterName: '1m',
};
}
if (currentTimeFilterSeconds < YEAR_IN_SECONDS && yearlyFeedLength > currentFeedLength) {
return {
i18nKey: 'more_threads_last_year',
timeFilterName: '1y',
};
}
return null;
};