fix(react): address Doctor findings (#1143)

Accessibility/semantics pass aligned with React Doctor: role-based spans/divs become real buttons, role=status becomes <output>, modals use native <dialog>, author/peer flags render as <img>, and embed/challenge iframes are sandboxed. Includes review follow-ups: correct button chrome/centering/themed dialog colors and focus rings, the missing aria-label i18n keys (all languages), the Yandex image-search URL, clearer .bso setup steps, a keyboard-accessible image-search dropdown, and removal of the post-edit ('comment edited') display (mod edits unchanged).
This commit is contained in:
Tommaso Casaburi
2026-05-29 17:09:07 +07:00
committed by GitHub
parent ac95e58433
commit e082c7b428
132 changed files with 1753 additions and 1224 deletions
@@ -23,14 +23,13 @@ type EditorState = {
};
const loadAce = async () => {
const aceModule = await import('react-ace');
const [workerJsonModule] = await Promise.all([
import('ace-builds/src-noconflict/worker-json?url'),
import('ace-builds/esm-resolver'),
import('ace-builds/src-noconflict/mode-json'),
import('ace-builds/src-noconflict/theme-monokai'),
]);
// Load react-ace first so esm-resolver sees the global ace instance.
const aceModulePromise = import('react-ace');
const workerJsonModulePromise = import('ace-builds/src-noconflict/worker-json?url');
const modeJsonPromise = import('ace-builds/src-noconflict/mode-json');
const themeMonokaiPromise = import('ace-builds/src-noconflict/theme-monokai');
const resolverPromise = aceModulePromise.then(() => import('ace-builds/esm-resolver'));
const [aceModule, workerJsonModule] = await Promise.all([aceModulePromise, workerJsonModulePromise, resolverPromise, modeJsonPromise, themeMonokaiPromise]);
// esm-resolver waits for react-ace so it can see the global ace instance.
const mod = aceModule.default;
const Editor = typeof mod === 'function' ? mod : (mod as unknown as { default: typeof mod }).default;
@@ -143,6 +142,7 @@ const AccountDataEditor = () => {
/>
) : (
<textarea
aria-label={t('account_data')}
value={text}
onChange={(e) => setEditorState((current) => ({ ...current, text: e.target.value }))}
style={{ width: '100%', height: '500px', fontFamily: 'monospace', fontSize: 13 }}
+1 -1
View File
@@ -241,7 +241,7 @@ const Archive = () => {
<tr>
<td className={styles.postblock}>No.</td>
<td className={styles.postblock}>Excerpt</td>
<td className={styles.postblock}></td>
<td className={styles.postblock} aria-label='Actions'></td>
</tr>
</thead>
<tbody>
+1 -1
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import blotterData from '../../data/5chan-blotter.json';
import BlotterMessage from '../../components/blotter-message';
import { formatBlotterDate, isBlotterEntry, sortBlotterEntries } from '../../lib/utils/blotter-utils';
import { Footer } from '../home';
import { Footer } from '../home/home';
import styles from './blotter.module.css';
const Blotter = () => {
+2 -2
View File
@@ -646,7 +646,7 @@ describe('Board', () => {
expect(latestLocation).toBe('/subs');
expect(container.textContent).toContain('not_subscribed_to_any_board');
expect(container.querySelector('[role="status"]')?.className).toContain('searchNothingFound');
expect(container.querySelector('output')?.className).toContain('searchNothingFound');
});
it('links empty mod feeds to account import settings', async () => {
@@ -660,7 +660,7 @@ describe('Board', () => {
expect(container.textContent).toContain('not_mod_of_any_board');
expect(container.textContent).toContain('go_to_settings_to_import_mod_account');
expect(container.querySelector('[role="status"]')?.className).toContain('modEmptyState');
expect(container.querySelector('output')?.className).toContain('modEmptyState');
expect(container.querySelector('a')?.getAttribute('href')).toBe('/mod/settings#account-settings');
});
+1
View File
@@ -8,6 +8,7 @@
}
.addressWarning {
display: block;
color: var(--mod-queue-alert-color, red);
padding: 15px 5px 0 5px;
}
+25 -29
View File
@@ -109,6 +109,7 @@ const BoardFooter = ({
1: onExpandTimeWindow ? (
<button
type='button'
aria-label={t('load_more')}
data-testid='expand-time-window-button'
className={styles.morePostsSuggestionAction}
onClick={() => {
@@ -137,9 +138,7 @@ const BoardFooter = ({
{communityState === 'failed' ? (
<span className='red'>{communityState}</span>
) : isInSubscriptionsView && subscriptionsLength === 0 ? (
<div className={styles.searchNothingFound} role='status'>
{t('not_subscribed_to_any_board')}
</div>
<output className={styles.searchNothingFound}>{t('not_subscribed_to_any_board')}</output>
) : isInModView && accountCommunityAddressesLength === 0 ? (
<ModEmptyState />
) : (
@@ -160,7 +159,7 @@ export interface BoardProps {
const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: BoardProps) => {
const { t } = useTranslation();
const location = useLocation();
const routerLocation = useLocation();
const params = useParams();
const isInAllView = viewType ? viewType === 'all' : false;
const isInSubscriptionsView = viewType ? viewType === 'subs' : false;
@@ -233,7 +232,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
const shouldProbeYearlyFeed = shouldProbeSuggestionFeeds && currentTimeFilterSeconds < YEAR_IN_SECONDS;
// Keep suggestion feeds on a stable hook identity; the loader widens them by paging, not by recreating the feed.
const suggestionPostsPerPage = infiniteFeedPostsPerPage;
const suggestionRequestKeyBase = `${location.pathname}${location.search}`;
const suggestionRequestKeyBase = `${routerLocation.pathname}${routerLocation.search}`;
const {
feed: weeklyFeed,
hasMore: weeklyFeedHasMore,
@@ -303,7 +302,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
[communityAddress],
);
const { accountComments: recentAccountComments } = useAccountComments(accountCommentLookupOptions);
const nonokoPendingAccountCommentIndex = getNonokoPendingAccountCommentIndex(location.state);
const nonokoPendingAccountCommentIndex = getNonokoPendingAccountCommentIndex(routerLocation.state);
const nonokoPendingAccountCommentLookupOptions = useMemo(
() =>
typeof nonokoPendingAccountCommentIndex === 'number'
@@ -315,7 +314,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
);
const { accountComments: nonokoPendingAccountComments } = useAccountComments(nonokoPendingAccountCommentLookupOptions);
const pathWithoutSettings = location.pathname.replace(/\/settings$/, '');
const pathWithoutSettings = routerLocation.pathname.replace(/\/settings$/, '');
const currentPage = getPageFromFeedPath(pathWithoutSettings);
const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings);
@@ -396,8 +395,8 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
const navigate = useNavigate();
const defaultFeedVirtualizationMode = isMobile && isMultiboardView ? 'off' : 'item-size';
const feedVirtualizationMode = useMemo(
() => resolveFeedVirtualizationMode(location.search, defaultFeedVirtualizationMode),
[defaultFeedVirtualizationMode, location.search],
() => resolveFeedVirtualizationMode(routerLocation.search, defaultFeedVirtualizationMode),
[defaultFeedVirtualizationMode, routerLocation.search],
);
const defaultBoardItemHeight = feedVirtualizationMode === 'item-size' ? (isMobile ? 420 : 480) : isMobile ? 420 : 300;
// Omit the prop entirely in fallback mode. Passing `itemSize={undefined}` overrides
@@ -407,20 +406,20 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
// Redirect multiboard paths with page-number segments to normalized path (infinite-scroll only)
useEffect(() => {
if (!isVisible || !isForcedInfiniteScroll) return;
const normalized = normalizeMultiboardFeedPath(location.pathname);
if (normalized !== location.pathname) {
navigate({ pathname: normalized, search: location.search }, { replace: true });
const normalized = normalizeMultiboardFeedPath(routerLocation.pathname);
if (normalized !== routerLocation.pathname) {
navigate({ pathname: normalized, search: routerLocation.search }, { replace: true });
}
}, [isVisible, isForcedInfiniteScroll, location.pathname, location.search, navigate]);
}, [isVisible, isForcedInfiniteScroll, routerLocation.pathname, routerLocation.search, navigate]);
useEffect(() => {
if (!isVisible) return;
if (!effectiveInfiniteScroll && currentPage > totalPages && totalPages > 0) {
const targetPage = totalPages;
const targetPath = targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`;
navigate({ pathname: targetPath, search: location.search }, { replace: true });
navigate({ pathname: targetPath, search: routerLocation.search }, { replace: true });
}
}, [isVisible, effectiveInfiniteScroll, currentPage, totalPages, paginationBasePath, location.search, navigate]);
}, [isVisible, effectiveInfiniteScroll, currentPage, totalPages, paginationBasePath, routerLocation.search, navigate]);
// Scroll to top instantly when page changes in pagination mode
useEffect(() => {
@@ -467,7 +466,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
currentTimeFilterName={currentTimeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
moreThreadsSuggestionSearch={routerLocation.search}
onExpandTimeWindow={expandSuggestionTimeWindow}
communityState={communityState}
subscriptionsLength={subscriptions?.length || 0}
@@ -479,7 +478,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
<BoardPagination
basePath={paginationBasePath}
currentPage={currentPage}
search={location.search}
search={routerLocation.search}
totalPages={totalPages}
footerStyle
isMultiboard={isForcedInfiniteScroll}
@@ -490,16 +489,16 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
<div>
{!isForcedInfiniteScroll && (
<div className={mobileFooterStyles.mobileFooterButtons}>
<button className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
<button type='button' className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
{t('start_new_thread')}
</button>
</div>
)}
<div className={mobileFooterStyles.mobileFooterButtons}>
<button className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
<button type='button' className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
{t('top')}
</button>
<button className='button' onClick={() => reset && reset()}>
<button type='button' className='button' onClick={() => reset && reset()}>
{t('refresh')}
</button>
</div>
@@ -511,7 +510,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
<span key={page}>
[
<Link
to={{ pathname: page === 1 ? paginationBasePath : `${paginationBasePath}/${page}`, search: location.search }}
to={{ pathname: page === 1 ? paginationBasePath : `${paginationBasePath}/${page}`, search: routerLocation.search }}
className={page === currentPage ? mobileFooterStyles.mobileFooterPaginationCurrent : undefined}
>
{page}
@@ -527,7 +526,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
)}
{hasMore && !effectiveInfiniteScroll && (
<div className={mobileFooterStyles.mobileFooterButtons}>
<button className='button' onClick={() => setEnableInfiniteScroll(true)}>
<button type='button' className='button' onClick={() => setEnableInfiniteScroll(true)}>
{t('load_more')}
</button>
</div>
@@ -541,6 +540,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
communityAddresses,
hasMore,
combinedFeed.length,
isInAllView,
isInSubscriptionsView,
isInModView,
currentTimeFilterName,
@@ -559,13 +559,13 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
totalPages,
setEnableInfiniteScroll,
reset,
location.search,
routerLocation.search,
t,
],
);
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}${location.search}-${BOARD_SORT_TYPE}`;
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${routerLocation.pathname}${routerLocation.search}-${BOARD_SORT_TYPE}`;
const navigationType = useNavigationType();
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 1200, top: 2400 }) : { bottom: 1200, top: 1200 };
const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
@@ -645,11 +645,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
<ErrorDisplay error={communityError} />
</div>
)}
{shouldShowUnverifiedAddressWarning && (
<div className={styles.addressWarning} role='status'>
{t('board_address_unverified_warning')}
</div>
)}
{shouldShowUnverifiedAddressWarning && <output className={styles.addressWarning}>{t('board_address_unverified_warning')}</output>}
{effectiveInfiniteScroll ? (
<Virtuoso
defaultItemHeight={defaultBoardItemHeight}
+5 -4
View File
@@ -3,7 +3,8 @@ import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog';
import Catalog, { type CatalogProps } from '../catalog';
import { getCatalogRenderFeed } from '../catalog-render-feed';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
import useHiddenCatalogThreadsStore from '../../../stores/use-hidden-catalog-threads-store';
@@ -920,7 +921,7 @@ describe('Catalog', () => {
expect(container.textContent).toContain('not_mod_of_any_board');
expect(container.textContent).toContain('go_to_settings_to_import_mod_account');
expect(container.querySelector('[role="status"]')?.className).toContain('modEmptyState');
expect(container.querySelector('output')?.className).toContain('modEmptyState');
expect(container.querySelector('a')?.getAttribute('href')).toBe('/mod/settings#account-settings');
expect(container.querySelectorAll('[data-testid="catalog-row"]')).toHaveLength(0);
});
@@ -934,8 +935,8 @@ describe('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.querySelector('output')?.className).toContain('searchNothingFound');
expect(container.querySelector('output')?.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');
+2
View File
@@ -0,0 +1,2 @@
export const getCatalogRenderFeed = <T>(processedFeed: readonly T[], deferredProcessedFeed: readonly T[]): readonly T[] =>
deferredProcessedFeed.length === 0 && processedFeed.length > 0 ? processedFeed : deferredProcessedFeed;
+33 -25
View File
@@ -44,6 +44,7 @@ import {
readReplyTypographyMetrics,
resolveCatalogVirtualizationMode,
} from '../../lib/utils/pretext-height-estimates';
import { getCatalogRenderFeed } from './catalog-render-feed';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
@@ -52,8 +53,6 @@ const MONTH_IN_SECONDS = 30 * 24 * 60 * 60;
const YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
// Keep the hook on its indexed fast path when this view should not inject local posts.
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
export const getCatalogRenderFeed = <T,>(processedFeed: readonly T[], deferredProcessedFeed: readonly T[]): readonly T[] =>
deferredProcessedFeed.length === 0 && processedFeed.length > 0 ? processedFeed : deferredProcessedFeed;
interface CatalogFooterProps {
communityAddresses: string[];
@@ -97,6 +96,7 @@ const CatalogFooter = ({
1: onExpandTimeWindow ? (
<button
type='button'
aria-label={t('load_more')}
data-testid='expand-time-window-button'
className={styles.morePostsSuggestionAction}
onClick={() => {
@@ -139,11 +139,7 @@ interface CatalogSearchNothingFoundProps {
const CatalogSearchNothingFound = ({ className }: CatalogSearchNothingFoundProps) => {
const { t } = useTranslation();
return (
<div className={className ?? styles.searchNothingFound} role='status'>
{t('nothing_found')}
</div>
);
return <output className={className ?? styles.searchNothingFound}>{t('nothing_found')}</output>;
};
interface CatalogLoadingProps {
@@ -188,6 +184,7 @@ const createContentFilter = (
) => {
// Create a unique key based on the enabled filter items
const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '');
const filterIndexByItem = new Map(filterItems.map((item, index) => [item, index]));
const filterKey =
enabledFilters.length > 0
? `content-filter-${enabledFilters.map((item) => `${item.text}-${item.hide ? 'hide' : ''}-${item.top ? 'top' : ''}`).join('-')}`
@@ -206,7 +203,7 @@ const createContentFilter = (
if (commentMatchesPattern(comment, pattern)) {
// Find the original filter index to increment count
const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled);
const filterIndex = filterIndexByItem.get(item) ?? -1;
if (trackMatches && filterIndex !== -1) {
if (onFilterMatch) {
onFilterMatch(filterIndex, comment.cid, communityAddress);
@@ -271,7 +268,7 @@ export interface CatalogProps {
const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: CatalogProps) => {
const { t } = useTranslation();
const location = useLocation();
const routerLocation = useLocation();
const navigate = useNavigate();
const params = useParams();
@@ -338,15 +335,15 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
// Canonical redirect for multiboard catalog paths with numeric page segment (e.g. /all/catalog/1w/5 -> /all/catalog/1w)
useEffect(() => {
if (!(isInAllView || isInSubscriptionsView || isInModView)) return;
const canonical = normalizeMultiboardFeedPath(location.pathname);
if (location.pathname !== canonical) {
navigate({ pathname: canonical, search: location.search }, { replace: true });
const canonical = normalizeMultiboardFeedPath(routerLocation.pathname);
if (routerLocation.pathname !== canonical) {
navigate({ pathname: canonical, search: routerLocation.search }, { replace: true });
}
}, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, location.search, navigate]);
}, [isInAllView, isInSubscriptionsView, isInModView, routerLocation.pathname, routerLocation.search, navigate]);
const sortType = useSortingStore((state) => state.sortType);
const feedSortType = sortType === 'new' ? 'new' : 'active';
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(routerLocation.search, 'item-size'), [routerLocation.search]);
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
const hadVisibleHiddenThreadsRef = useRef(false);
@@ -441,7 +438,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
);
// Keep suggestion feeds on a stable hook identity; the loader widens them by paging, not by recreating the feed.
const suggestionPostsPerPage = multiboardCatalogPostsPerPage;
const suggestionRequestKeyBase = `${location.pathname}${location.search}`;
const suggestionRequestKeyBase = `${routerLocation.pathname}${routerLocation.search}`;
const {
feed: weeklyFeed,
hasMore: weeklyFeedHasMore,
@@ -599,10 +596,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 catalogFooterFirstRow = useMemo(
() => <CatalogFooterFirstRow communityAddress={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />,
[communityAddress, isInAllView, isInSubscriptionsView, isInModView],
);
const catalogFooterStyleRow = <CatalogFooterStyleRow />;
const catalogFooterStyleRow = useMemo(() => <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
@@ -617,7 +615,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
currentTimeFilterName={currentTimeFilterName}
moreThreadsSuggestion={footerMoreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
moreThreadsSuggestionSearch={routerLocation.search}
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
@@ -647,7 +645,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
isInAllView,
isInSubscriptionsView,
isInModView,
location.search,
routerLocation.search,
footerShowLoadingEllipsis,
],
);
@@ -661,7 +659,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
currentTimeFilterName={currentTimeFilterName}
moreThreadsSuggestion={footerMoreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
moreThreadsSuggestionSearch={routerLocation.search}
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
@@ -690,7 +688,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
isInAllView,
isInSubscriptionsView,
isInModView,
location.search,
routerLocation.search,
footerShowLoadingEllipsis,
],
);
@@ -757,7 +755,13 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
continue;
}
const firstMatch = activeColoredFilters.find((item) => commentMatchesPattern(comment, item.text));
let firstMatch: (typeof activeColoredFilters)[number] | undefined;
for (const filter of activeColoredFilters) {
if (commentMatchesPattern(comment, filter.text)) {
firstMatch = filter;
break;
}
}
if (firstMatch?.color) {
nextMatchedFilterColors.set(cid, firstMatch.color);
}
@@ -788,7 +792,11 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return nextRows;
}, [catalogRenderFeed, columnCount, isFeedLoaded]);
const catalogMetrics = useMemo(() => readReplyTypographyMetrics(), [themeKey, windowWidth]);
const catalogMetrics = useMemo(() => {
void themeKey;
void windowWidth;
return readReplyTypographyMetrics();
}, [themeKey, windowWidth]);
const rowHeightEstimates = useMemo(
() =>
catalogVirtualizationMode === 'off'
@@ -812,7 +820,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey
? `${feedCacheKey}-${sortType}-${processedFeedMode}`
: `${location.pathname}${location.search}-${sortType}-${processedFeedMode}-catalog`;
: `${routerLocation.pathname}${routerLocation.search}-${sortType}-${processedFeedMode}-catalog`;
const navigationType = useNavigationType();
const hasBeenVisibleRef = useRef(false);
@@ -288,6 +288,6 @@ describe('Directory', () => {
const cells = Array.from(getDirectoryRow('board-6.bso')?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
expect(cells[3]).toBe('—?');
expect(getDirectoryRow('board-6.bso')?.querySelector('sup')?.closest('span')?.getAttribute('title')).toBe('directory_status_unavailable_reason');
expect(getDirectoryRow('board-6.bso')?.querySelector('button[aria-label="directory_status_unavailable_reason"]')).not.toBeNull();
});
});
+9
View File
@@ -196,11 +196,20 @@
}
.statusUnavailableHelp {
all: unset;
margin-left: 2px;
font-weight: 700;
font-size: 0.75em;
vertical-align: super;
line-height: 0;
cursor: help;
}
.statusUnavailableHelp:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.directoryFootnote {
width: 80%;
max-width: 810px;
+3 -3
View File
@@ -129,9 +129,9 @@ const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) =>
<span className={styles.statusUnavailable}>
{DIRECTORY_STATUS_UNAVAILABLE_MARKER}
<Tooltip content={statusUnavailableReason}>
<sup className={styles.statusUnavailableHelp} aria-label={statusUnavailableReason} tabIndex={0}>
<button type='button' className={styles.statusUnavailableHelp} aria-label={statusUnavailableReason} tabIndex={0}>
?
</sup>
</button>
</Tooltip>
</span>
) : status === 'loading' ? (
@@ -252,7 +252,7 @@ const Directory = () => {
values={{ boardIdentifier }}
components={{
passLink: <Link to={PASS_LINK} />,
repoLink: <a href={repoEditUrl} target='_blank' rel='noreferrer noopener' />,
repoLink: <a href={repoEditUrl} target='_blank' rel='noreferrer noopener' aria-label={t('directory_submit_repo_link_label', 'directory repository')} />,
}}
/>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
import { type ReactNode, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { HashLink } from 'react-router-hash-link';
import { Footer, HomeLogo } from '../home';
import { Footer, HomeLogo } from '../home/home';
import styles from './faq.module.css';
const externalLinkProps = {
@@ -8,7 +8,7 @@ const BoardsFilterModal = () => {
const { t } = useTranslation();
const [showFilterModal, setShowFilterModal] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLSpanElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { useCatalogLinks, setUseCatalogLinks, boardFilter, setBoardFilter } = useBoardsFilterStore();
@@ -38,9 +38,9 @@ const BoardsFilterModal = () => {
return (
<>
<span
<button
type='button'
ref={buttonRef}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -51,13 +51,13 @@ const BoardsFilterModal = () => {
onClick={() => !showFilterModal && setShowFilterModal(true)}
>
{t('filter')}
</span>
</button>
{showFilterModal && (
<div ref={modalRef} className={styles.filterModal}>
{/* Always shown: Use Catalog */}
<div
<button
type='button'
className={`${styles.option} ${useCatalogLinks && styles.selected}`}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -72,15 +72,15 @@ const BoardsFilterModal = () => {
}}
>
{t('use_catalog')}
</div>
</button>
{/* Conditionally shown: Filtering options (only after disclaimer accepted) */}
{disclaimerAccepted && (
<>
<div className={styles.separator} />
<div
<button
type='button'
className={`${styles.option} ${boardFilter === 'all' && styles.selected}`}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -95,10 +95,10 @@ const BoardsFilterModal = () => {
}}
>
{t('show_all_boards')}
</div>
<div
</button>
<button
type='button'
className={`${styles.option} ${boardFilter === 'nsfw' && styles.selected}`}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -113,10 +113,10 @@ const BoardsFilterModal = () => {
}}
>
{t('show_nsfw_boards_only')}
</div>
<div
</button>
<button
type='button'
className={`${styles.option} ${boardFilter === 'worksafe' && styles.selected}`}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -131,7 +131,7 @@ const BoardsFilterModal = () => {
}}
>
{t('show_worksafe_boards_only')}
</div>
</button>
</>
)}
</div>
+13 -13
View File
@@ -7,7 +7,7 @@ const BoxModal = () => {
const { t } = useTranslation();
const [showFilterModal, setShowFilterModal] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLSpanElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { showWorksafeContentOnly, setShowWorksafeContentOnly, showNsfwContentOnly, setShowNsfwContentOnly } = useHomeFiltersStore();
@@ -45,9 +45,9 @@ const BoxModal = () => {
return (
<>
<span
<button
type='button'
ref={buttonRef}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -58,36 +58,36 @@ const BoxModal = () => {
onClick={() => !showFilterModal && setShowFilterModal(true)}
>
{t('options')}
</span>
</button>
{showFilterModal && (
<div ref={modalRef} className={styles.filterModal}>
<div
<button
type='button'
className={`${styles.option} ${showWorksafeContentOnly ? styles.selected : ''}`}
role='button'
tabIndex={0}
onKeyDown={(e) => handleKey(e, 'worksafe')}
onClick={() => selectFilter('worksafe')}
>
{t('show_worksafe_content_only')}
</div>
<div
</button>
<button
type='button'
className={`${styles.option} ${showNsfwContentOnly ? styles.selected : ''}`}
role='button'
tabIndex={0}
onKeyDown={(e) => handleKey(e, 'nsfw')}
onClick={() => selectFilter('nsfw')}
>
{t('show_nsfw_content_only')}
</div>
<div
</button>
<button
type='button'
className={`${styles.option} ${!showWorksafeContentOnly && !showNsfwContentOnly ? styles.selected : ''}`}
role='button'
tabIndex={0}
onKeyDown={(e) => handleKey(e, 'all')}
onClick={() => selectFilter('all')}
>
{t('show_all_content')}
</div>
</button>
</div>
)}
</>
+21 -3
View File
@@ -57,15 +57,19 @@
font-weight: 700;
}
.boxBar span {
.boxBar span, .boxBar > button {
all: unset;
position: absolute;
top: 0;
right: 0;
padding-right: 0.25em;
text-transform: lowercase;
font: inherit;
color: inherit;
line-height: inherit;
}
.boxBar span:hover {
.boxBar span:hover, .boxBar > button:hover {
cursor: pointer;
color: var(--homepage-box-bar-text-color-hover);
}
@@ -148,7 +152,15 @@
}
.filterModal .option {
all: unset;
box-sizing: border-box;
display: block;
width: 100%;
padding: 0 10px;
color: inherit;
font: inherit;
line-height: inherit;
text-align: right;
}
.filterModal .option:hover {
@@ -156,6 +168,12 @@
cursor: pointer;
}
.boxBar > button:focus-visible,
.filterModal .option:focus-visible {
outline: 1px solid currentcolor;
outline-offset: -1px;
}
.filterModal .selected::before {
content: '✓ ';
}
@@ -389,4 +407,4 @@
.footer a:hover {
color: var(--homepage-box-bar-text-color-hover);
}
}
}
+13 -2
View File
@@ -44,10 +44,13 @@ const SearchBar = () => {
spellCheck='false'
autoCapitalize='off'
type='text'
aria-label={lowerCase(t('enter_board_address'))}
placeholder={lowerCase(t('enter_board_address'))}
ref={searchInputRef}
/>
<button className={styles.searchButton}>{t('go')}</button>
<button type='submit' className={styles.searchButton}>
{t('go')}
</button>
</form>
</div>
);
@@ -77,7 +80,15 @@ const InfoBox = () => {
i18nKey='no_global_rules_info'
shouldUnescape={true}
components={{
1: <a key='releases-link' href='https://github.com/bitsocialnet/5chan/releases/latest' target='_blank' rel='noopener noreferrer' />,
1: (
<a
key='releases-link'
href='https://github.com/bitsocialnet/5chan/releases/latest'
target='_blank'
rel='noopener noreferrer'
aria-label='5chan releases'
/>
),
}}
/>
) : (
+8 -10
View File
@@ -49,8 +49,6 @@ import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '..
import footerStyles from '../../components/footer/footer.module.css';
import { useModeratedCommunityAddresses } from '../../hooks/use-moderated-community-addresses';
const { addChallenge } = useChallengesStore.getState();
/** Path for display: directory code, or full address if has TLD, or shortened for long IPNS keys (no dot) */
const getBoardDisplayPath = (address: string, path: string): string => {
if (path !== address) return path;
@@ -179,7 +177,7 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr
variant === 'row' ? (
<span className={styles.buttonWrapper}>
[
<button className={styles.button} onClick={handleRemove} disabled={isPublishing}>
<button type='button' className={styles.button} onClick={handleRemove} disabled={isPublishing}>
{t('modQueue.dismiss')}
</button>
]
@@ -187,7 +185,7 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr
) : (
<span className={styles.cardRemoveButtonWrapper}>
[
<button className={styles.cardRemoveButton} onClick={handleRemove} disabled={isPublishing}>
<button type='button' className={styles.cardRemoveButton} onClick={handleRemove} disabled={isPublishing}>
{t('modQueue.dismiss')}
</button>
]
@@ -239,14 +237,14 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr
<div className={styles.actionButtons}>
<span className={styles.buttonWrapper}>
[
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
<button type='button' className={styles.button} onClick={handleApprove} disabled={isPublishing}>
{t('approve')}
</button>
]
</span>
<span className={styles.buttonWrapper}>
[
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
<button type='button' className={styles.button} onClick={handleReject} disabled={isPublishing}>
{t('reject')}
</button>
]
@@ -254,10 +252,10 @@ const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleAppr
</div>
) : (
<div className={styles.cardActions}>
<button className={`button ${styles.cardApproveButton}`} onClick={handleApprove} disabled={isPublishing}>
<button type='button' className={`button ${styles.cardApproveButton}`} onClick={handleApprove} disabled={isPublishing}>
{t('approve')}
</button>
<button className={`button ${styles.cardRejectButton}`} onClick={handleReject} disabled={isPublishing}>
<button type='button' className={`button ${styles.cardRejectButton}`} onClick={handleReject} disabled={isPublishing}>
{t('reject')}
</button>
</div>
@@ -286,7 +284,7 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
communityAddress,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, comment]);
useChallengesStore.getState().addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
@@ -305,7 +303,7 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
communityAddress,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, comment]);
useChallengesStore.getState().addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
+2 -2
View File
@@ -149,7 +149,7 @@ const Pass = () => {
<Trans
i18nKey='pass_answer_nft'
components={{
mintPass: <a href='https://github.com/bitsocialnet/mintpass' target='_blank' rel='noopener noreferrer' />,
mintPass: <a href='https://github.com/bitsocialnet/mintpass' target='_blank' rel='noopener noreferrer' aria-label='Mintpass' />,
}}
/>
</dd>
@@ -158,7 +158,7 @@ const Pass = () => {
<Trans
i18nKey='pass_answer_proceeds'
components={{
bitsocial: <a href='https://bitsocial.net' target='_blank' rel='noopener noreferrer' />,
bitsocial: <a href='https://bitsocial.net' target='_blank' rel='noopener noreferrer' aria-label='Bitsocial' />,
}}
/>
</dd>
+54 -11
View File
@@ -34,6 +34,9 @@
}
.postDesktop .hideButton {
appearance: none;
background-color: transparent;
border: 0;
background-repeat: no-repeat;
background-position: center;
width: 18px;
@@ -42,6 +45,8 @@
image-rendering: pixelated;
cursor: pointer;
transform: translateY(-1px);
margin: 0;
padding: 0;
}
.postDesktopHidden {
@@ -109,6 +114,12 @@
}
.userAddress {
appearance: none;
border: 0;
display: inline;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0 5px;
border-radius: 6px;
font-size: 0.8em;
@@ -144,12 +155,18 @@
image-rendering: pixelated;
}
.postDesktop .postNumLink a, .postDesktop .postNumLink span, .replyDesktop .postNumLink a, .replyDesktop .postNumLink span {
.postDesktop .postNumLink a, .postDesktop .postNumLink span, .postDesktop .postNumLink button, .replyDesktop .postNumLink a, .replyDesktop .postNumLink span, .replyDesktop .postNumLink button {
appearance: none;
background: transparent;
border: 0;
color: unset;
font: inherit;
margin: 0;
padding: 0;
text-decoration: unset;
}
.postDesktop .postNumLink a:hover, .postDesktop .postNumLink span:hover, .replyDesktop .postNumLink a:hover, .replyDesktop .postNumLink span:hover {
.postDesktop .postNumLink a:hover, .postDesktop .postNumLink span:hover, .postDesktop .postNumLink button:hover, .replyDesktop .postNumLink a:hover, .replyDesktop .postNumLink span:hover, .replyDesktop .postNumLink button:hover {
color: var(--button-desktop-text-color-hover);
}
@@ -179,7 +196,13 @@
}
.postDesktop .closeMedia {
appearance: none;
background: transparent;
border: 0;
color: var(--button-desktop-text-color);
font: inherit;
margin: 0;
padding: 0;
text-decoration: underline;
text-transform: capitalize;
}
@@ -310,6 +333,9 @@
}
.postDesktop .summary .omittedRepliesButtonWrapper {
appearance: none;
background-color: transparent;
border: 0;
background-repeat: no-repeat;
background-position: center;
width: 18px;
@@ -317,6 +343,8 @@
display: inline-block;
image-rendering: pixelated;
cursor: pointer;
margin: 0;
padding: 0;
}
.postDesktop .summary .hideOmittedReplies {
@@ -361,7 +389,7 @@
justify-content: center;
}
.mobileUnhideButton span {
.mobileUnhideButton span, .mobileUnhideButton button {
padding: 5px 18px 5px;
}
@@ -600,7 +628,14 @@
}
.replyToPost {
appearance: none;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font: inherit;
margin: 0;
padding: 0;
}
.redEditMessage {
@@ -695,22 +730,30 @@
padding: 1.5px 0;
}
.editedInfo {
color: var(--post-mobile-abbr-text-color);
font-size: var(--post-mobile-abbr-font-size);
}
.showOriginal {
.abbr button {
appearance: none;
background: transparent;
border: 0;
color: var(--post-link-text-color);
cursor: pointer;
display: inline;
font: inherit;
line-height: inherit;
margin: 0;
padding: 0;
text-decoration: var(--post-content-link-text-decoration);
}
.showOriginal:hover {
cursor: pointer;
.abbr button:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-content-link-text-decoration-hover);
}
.abbr button:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
@media (max-width: 640px) {
.replyQuotePreview {
margin-right: 10px;
+9 -3
View File
@@ -283,7 +283,13 @@ const PostPage = () => {
const resolvedCommunityAddress = useResolvedCommunityAddress();
const resolvedCommunityIdentifier = useCommunityIdentifier(resolvedCommunityAddress);
const isInAllView = isAllView(pathname);
const routeState = useMemo(() => getEffectiveRouteUserState(locationState), [locationKey, pathname, locationState]);
const routeState = useMemo(() => {
// locationKey/pathname are intentional deps: getEffectiveRouteUserState falls back to the
// non-reactive window.history.state, so the memo must re-run on every navigation to re-read it.
void locationKey;
void pathname;
return getEffectiveRouteUserState(locationState);
}, [locationKey, pathname, locationState]);
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled, community: resolvedCommunityIdentifier });
const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]);
@@ -370,16 +376,16 @@ const PostPage = () => {
const queuedReplyHasMore = queuedReplyRepliesResult.hasMore;
const queuedReplyLoadMore = queuedReplyRepliesResult.loadMore;
const queuedReplyReset = (queuedReplyRepliesResult as { reset?: () => Promise<void> }).reset;
const queuedReplyReplies = (queuedReplyRepliesResult.updatedReplies?.length ? queuedReplyRepliesResult.updatedReplies : queuedReplyRepliesResult.replies) || [];
const replyPaginationOverride = useMemo(() => {
if (!queuedReply || !post?.cid) return undefined;
const queuedReplyReplies = (queuedReplyRepliesResult.updatedReplies?.length ? queuedReplyRepliesResult.updatedReplies : queuedReplyRepliesResult.replies) || [];
return {
hasMore: queuedReplyHasMore,
loadMore: queuedReplyLoadMore,
replies: mergeRepliesWithQueuedReply(queuedReplyReplies, queuedReply),
reset: queuedReplyReset,
};
}, [post?.cid, queuedReply, queuedReplyHasMore, queuedReplyLoadMore, queuedReplyReplies, queuedReplyReset]);
}, [post?.cid, queuedReply, queuedReplyHasMore, queuedReplyLoadMore, queuedReplyRepliesResult.replies, queuedReplyRepliesResult.updatedReplies, queuedReplyReset]);
useEffect(() => {
return () => {
+6 -5
View File
@@ -63,7 +63,7 @@ const BoardRulesDisplay = ({ communityAddress, directories }: { communityAddress
) : rules && rules.length > 0 ? (
<ol>
{rules.map((rule: string, index: number) => (
<li key={index}>
<li key={`${index}-${rule}`}>
<Markdown content={rule} />
</li>
))}
@@ -117,9 +117,9 @@ const BoardSelector = ({
<div className={styles.boxContent}>
<div className={styles.selectorRow}>
<select value={selectedBoardValue} onChange={handleSelectChange} className={styles.boardSelect}>
<option value=''>Select board...</option>
{[...directories]
.sort((a, b) => getBoardShortCode(a.title).localeCompare(getBoardShortCode(b.title)))
<option value=''>Select board&hellip;</option>
{directories
.toSorted((a, b) => getBoardShortCode(a.title).localeCompare(getBoardShortCode(b.title)))
.map((sub) => {
const shortCode = getBoardShortCode(sub.title);
const boardName = getBoardName(sub.title);
@@ -134,13 +134,14 @@ const BoardSelector = ({
<form onSubmit={handleCustomSubmit} className={styles.customAddressForm}>
<input
type='text'
aria-label={lowerCase(t('enter_board_address'))}
placeholder={lowerCase(t('enter_board_address'))}
value={customAddress}
onChange={(e) => setCustomAddress(e.target.value)}
className={styles.addressInput}
/>
<button type='submit' className={styles.goButton}>
Go
Open Board
</button>
</form>
</div>