From 556973a44571d0fe8ce6203cba7d524419b17290 Mon Sep 17 00:00:00 2001
From: Tommaso Casaburi
Date: Sun, 10 May 2026 18:25:13 +0700
Subject: [PATCH] Improve React Doctor score and badge (#1127)
* fix(react doctor): improve quality score and badge
* fix(react doctor): address review feedback
* fix(review): address final bot feedback
---
.github/workflows/ci.yml | 22 +-
README.md | 1 +
package.json | 1 +
scripts/write-react-doctor-badge.mjs | 44 ++++
src/app.tsx | 38 ++-
.../board-buttons/board-buttons.tsx | 16 +-
.../__tests__/board-header.test.tsx | 2 +-
.../__tests__/boards-bar-edit-modal.test.tsx | 75 ++++++
.../boards-bar-edit-modal.tsx | 10 +-
src/components/boards-bar/boards-bar.tsx | 30 +--
.../__tests__/catalog-filters.test.tsx | 7 +-
.../catalog-filters/catalog-filters.tsx | 211 ++++++++--------
.../filters-protip/filters-protip.tsx | 30 +--
.../highlight-color-picker.module.css | 10 +
.../highlight-color-picker.tsx | 9 +-
.../catalog-search/catalog-search.tsx | 10 +-
.../__tests__/challenge-modal.test.tsx | 2 +-
.../challenge-modal/challenge-modal.tsx | 22 +-
.../comment-content/comment-content.tsx | 2 +-
.../create-board-modal/create-board-modal.tsx | 4 +-
.../directory-modal/directory-modal.tsx | 2 +-
.../markdown/__tests__/markdown.test.tsx | 38 +++
.../markdown/external-number-quote-link.tsx | 6 +-
src/components/markdown/markdown.tsx | 233 +++++++++++-------
.../post-menu-desktop/post-menu-desktop.tsx | 18 +-
src/components/post-form/post-form.tsx | 8 +-
.../post-menu-mobile/post-menu-mobile.tsx | 36 +--
.../__tests__/reply-modal.test.tsx | 44 +++-
src/components/reply-modal/reply-modal.tsx | 89 ++++---
.../account-settings/account-settings.tsx | 4 +-
.../advanced-settings/advanced-settings.tsx | 28 +--
.../crypto-wallets-setting.tsx | 2 +-
.../p2p-stats-settings/p2p-stats-settings.tsx | 9 +-
.../settings-modal/settings-modal.tsx | 10 +-
.../subscriptions-setting.tsx | 6 +-
src/e2e/pretext-benchmark-harness.tsx | 10 +-
src/hooks/use-community-identifiers.ts | 4 +-
src/hooks/use-directories.ts | 2 +-
src/lib/utils/blotter-utils.ts | 2 +-
src/lib/utils/pretext-height-estimates.ts | 6 +-
src/lib/utils/route-utils.ts | 2 +-
src/lib/utils/view-utils.ts | 2 +-
src/views/archive/__tests__/helpers.ts | 2 +-
src/views/archive/index.ts | 1 -
.../home/boards-list/boards-filter-modal.tsx | 21 +-
src/views/mod-queue/mod-queue.tsx | 63 +++--
src/views/post/post.tsx | 19 +-
47 files changed, 771 insertions(+), 442 deletions(-)
create mode 100644 scripts/write-react-doctor-badge.mjs
create mode 100644 src/components/boards-bar-edit-modal/__tests__/boards-bar-edit-modal.test.tsx
delete mode 100644 src/views/archive/index.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 71ab06e9..87c83f3d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -96,6 +96,18 @@ jobs:
if: github.event_name == 'pull_request' && steps.react-ui-changes.outputs.changed != 'true'
run: echo "Skipping React Doctor because this pull request did not change React UI source."
+ - name: Write React Doctor badge payload
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+ run: node scripts/write-react-doctor-badge.mjs
+
+ - name: Upload React Doctor badge
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+ uses: actions/upload-artifact@v4
+ with:
+ name: react-doctor-badge
+ path: badges/react-doctor.json
+ if-no-files-found: error
+
- name: Install Chromium for smoke tests
run: npx playwright install --with-deps chromium
@@ -111,7 +123,7 @@ jobs:
if-no-files-found: warn
publish-coverage-badge:
- name: Publish Coverage Badge
+ name: Publish Badges
runs-on: ubuntu-22.04
needs: quality
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
@@ -132,13 +144,21 @@ jobs:
name: coverage-badge
path: ${{ runner.temp }}/coverage-badge
+ - name: Download React Doctor badge
+ uses: actions/download-artifact@v4
+ with:
+ name: react-doctor-badge
+ path: ${{ runner.temp }}/react-doctor-badge
+
- name: Prepare Pages artifact
env:
BADGE_SOURCE_PATH: ${{ runner.temp }}/coverage-badge/coverage.json
+ REACT_DOCTOR_BADGE_SOURCE_PATH: ${{ runner.temp }}/react-doctor-badge/react-doctor.json
PAGES_OUTPUT_PATH: ${{ runner.temp }}/github-pages
run: |
mkdir -p "${PAGES_OUTPUT_PATH}/badges"
cp "${BADGE_SOURCE_PATH}" "${PAGES_OUTPUT_PATH}/badges/coverage.json"
+ cp "${REACT_DOCTOR_BADGE_SOURCE_PATH}" "${PAGES_OUTPUT_PATH}/badges/react-doctor.json"
touch "${PAGES_OUTPUT_PATH}/.nojekyll"
- name: Upload Pages artifact
diff --git a/README.md b/README.md
index 92f5a4e9..9315819d 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
[](https://github.com/bitsocialnet/5chan/actions/workflows/ci.yml)
[](https://github.com/bitsocialnet/5chan/blob/master/scripts/write-coverage-badge.mjs)
+[](https://github.com/bitsocialnet/5chan/actions/workflows/ci.yml)
[](https://github.com/bitsocialnet/5chan/releases/latest)
[](https://github.com/bitsocialnet/5chan/blob/master/LICENSE)
[](http://commitizen.github.io/cz-cli/)
diff --git a/package.json b/package.json
index 22b268f0..5fa543b1 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
"doctor": "react-doctor . -y",
"doctor:score": "react-doctor . --score -y",
"doctor:verbose": "react-doctor . --verbose -y",
+ "doctor:badge": "node scripts/write-react-doctor-badge.mjs",
"contract:imgur": "cd android && ./gradlew :app:connectedDebugAndroidTest -Pandroid.experimental.androidTest.useUnifiedTestPlatform=false -Pandroid.testInstrumentationRunnerArguments.class=fivechan.android.MediaUploadAutomationRunnerTest",
"smoke:upload-selectors": "node scripts/smoke-upload-selectors.js",
"test:coverage": "vitest run --coverage.enabled --coverage.provider=istanbul --coverage.reporter=text --coverage.reporter=json --coverage.reporter=json-summary --coverage.reportsDirectory=./coverage",
diff --git a/scripts/write-react-doctor-badge.mjs b/scripts/write-react-doctor-badge.mjs
new file mode 100644
index 00000000..a0c50ff2
--- /dev/null
+++ b/scripts/write-react-doctor-badge.mjs
@@ -0,0 +1,44 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+
+const CWD = process.cwd();
+const BADGE_OUTPUT_PATH = path.join(CWD, "badges", "react-doctor.json");
+const reactDoctorArgs = ["react-doctor", ".", "--json", "--json-compact", "--yes", "--fail-on", "none"];
+
+console.log(`[react-doctor-badge] Running "yarn ${reactDoctorArgs.join(" ")}" in "${CWD}".`);
+
+const reportText = execFileSync("yarn", reactDoctorArgs, {
+ cwd: CWD,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "inherit"],
+});
+
+let report;
+try {
+ report = JSON.parse(reportText);
+} catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(`[react-doctor-badge] Failed to parse React Doctor JSON report: ${message}`);
+ console.error(`[react-doctor-badge] Output preview: ${reportText.slice(0, 500)}`);
+ process.exit(1);
+}
+const score = report?.summary?.score;
+
+if (typeof score !== "number") {
+ console.error("[react-doctor-badge] Missing summary.score in React Doctor JSON report.");
+ process.exit(1);
+}
+
+const color = score >= 90 ? "brightgreen" : score >= 75 ? "green" : score >= 50 ? "yellow" : "red";
+const badge = {
+ schemaVersion: 1,
+ label: "react doctor",
+ message: `${score}/100`,
+ color,
+};
+
+fs.mkdirSync(path.dirname(BADGE_OUTPUT_PATH), { recursive: true });
+fs.writeFileSync(BADGE_OUTPUT_PATH, `${JSON.stringify(badge, null, 2)}\n`);
+
+console.log(`[react-doctor-badge] Wrote "${BADGE_OUTPUT_PATH}" with score ${score}/100.`);
diff --git a/src/app.tsx b/src/app.tsx
index 8cffc979..0c2049a0 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -67,22 +67,22 @@ preloadReplyModal();
const BoardLayout = () => {
const params = useParams();
const { accountCommentIndex, boardIdentifier, pageNumber } = params;
- const location = useLocation();
+ const { pathname, search } = useLocation();
const isMobile = useIsMobile();
- const isInAllView = isAllView(location.pathname);
- const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
- const isInModView = isModView(location.pathname);
+ const isInAllView = isAllView(pathname);
+ const isInSubscriptionsView = isSubscriptionsView(pathname, useParams());
+ const isInModView = isModView(pathname);
const directories = useDirectories();
const communityAddress = boardIdentifier ? getCommunityAddress(boardIdentifier, directories) : undefined;
const pendingPost = useSafeAccountComment({ commentIndex: accountCommentIndex });
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
const { closeCreateBoardModal } = useCreateBoardModalStore();
- const isOnPostRoute = isPostRoute(location.pathname);
- const isOnPendingPostRoute = isPendingPostRoute(location.pathname);
- const isOnModQueueRoute = isModQueueRoute(location.pathname);
- const isOnArchiveRoute = isArchiveRoute(location.pathname);
+ const isOnPostRoute = isPostRoute(pathname);
+ const isOnPendingPostRoute = isPendingPostRoute(pathname);
+ const isOnModQueueRoute = isModQueueRoute(pathname);
+ const isOnArchiveRoute = isArchiveRoute(pathname);
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute;
- const isInCatalogView = isCatalogView(location.pathname, params);
+ const isInCatalogView = isCatalogView(pathname, params);
// Christmas theme
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
useEffect(() => {
@@ -97,28 +97,26 @@ const BoardLayout = () => {
// Close create board modal when navigating to a different page
useEffect(() => {
closeCreateBoardModal();
- }, [location.pathname, closeCreateBoardModal]);
+ }, [pathname, closeCreateBoardModal]);
// force rerender of post form when navigating between pages, except when opening settings modal in current view
- const key = location.pathname.endsWith('/settings')
- ? `${communityAddress}-${location.pathname.replace(/\/settings$/, '')}`
- : `${communityAddress}-${location.pathname}`;
+ const key = pathname.endsWith('/settings') ? `${communityAddress}-${pathname.replace(/\/settings$/, '')}` : `${communityAddress}-${pathname}`;
if (pageNumber === '1') {
return ;
}
// Invalid /mod/ paths (e.g. /mod/modqueue, /mod/asdoijasd) -> not-found
- if (location.pathname.startsWith('/mod/') && !isValidModRoute(location.pathname)) {
+ if (pathname.startsWith('/mod/') && !isValidModRoute(pathname)) {
return ;
}
- if (isLegacyBoardModQueueRoute(location.pathname)) {
+ if (isLegacyBoardModQueueRoute(pathname)) {
return ;
}
// Invalid board-scoped mod paths (e.g. /biz/mod, /biz/mod/asdoijasd) -> not-found
- if (isBoardModRoute(location.pathname) && !isValidBoardModRoute(location.pathname)) {
+ if (isBoardModRoute(pathname) && !isValidBoardModRoute(pathname)) {
return ;
}
@@ -126,8 +124,8 @@ const BoardLayout = () => {
if (boardIdentifier && !isDirectoryBoard(boardIdentifier, directories)) {
const canonicalBoardIdentifier = getBoardPath(boardIdentifier, directories);
if (canonicalBoardIdentifier !== boardIdentifier) {
- const canonicalPath = location.pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
- return ;
+ const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
+ return ;
}
}
@@ -201,8 +199,8 @@ const GlobalLayout = () => {
})),
);
- const location = useLocation();
- const isInSettingsView = location.pathname.endsWith('/settings');
+ const { pathname } = useLocation();
+ const isInSettingsView = pathname.endsWith('/settings');
return (
<>
diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx
index 9573d92a..a107b335 100644
--- a/src/components/board-buttons/board-buttons.tsx
+++ b/src/components/board-buttons/board-buttons.tsx
@@ -221,11 +221,11 @@ export const AutoButton = () => {
export const BottomButton = () => {
const { t } = useTranslation();
- const handleClick = () => {
+ const scrollToBottom = () => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' });
};
return (
-
+
{t('bottom')}
);
@@ -233,11 +233,11 @@ export const BottomButton = () => {
export const TopButton = () => {
const { t } = useTranslation();
- const handleClick = () => {
+ const scrollToTop = () => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
};
return (
-
+
{t('top')}
);
@@ -507,13 +507,13 @@ export const MobileBoardButtons = () => {
{searchText ? (
{' '}
- — {t('search_results_for')}: {searchText}
+ - {t('search_results_for')}: {searchText}
) : (
filteredCount > 0 && (
{' '}
- — {t('filtered_threads')}: {filteredCount}
+ - {t('filtered_threads')}: {filteredCount}
)
)}
@@ -714,14 +714,14 @@ export const DesktopBoardButtons = () => {
{isInCatalogView && searchText ? (
{' '}
- — {t('search_results_for')}: {searchText}
+ - {t('search_results_for')}: {searchText}
) : (
isInCatalogView &&
filteredCount > 0 && (
{' '}
- — {t('filtered_threads')}: {filteredCount}
+ - {t('filtered_threads')}: {filteredCount}
)
)}
diff --git a/src/components/board-header/__tests__/board-header.test.tsx b/src/components/board-header/__tests__/board-header.test.tsx
index f96f410f..1c0febed 100644
--- a/src/components/board-header/__tests__/board-header.test.tsx
+++ b/src/components/board-header/__tests__/board-header.test.tsx
@@ -62,7 +62,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
selector({
accounts: {
active: {
- subscriptions: new Array(testState.subscriptionsCount).fill('sub'),
+ subscriptions: Array.from({ length: testState.subscriptionsCount }, () => 'sub'),
},
},
activeAccountId: 'active',
diff --git a/src/components/boards-bar-edit-modal/__tests__/boards-bar-edit-modal.test.tsx b/src/components/boards-bar-edit-modal/__tests__/boards-bar-edit-modal.test.tsx
new file mode 100644
index 00000000..746cbff0
--- /dev/null
+++ b/src/components/boards-bar-edit-modal/__tests__/boards-bar-edit-modal.test.tsx
@@ -0,0 +1,75 @@
+import * as React from 'react';
+import { createElement } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { MemoryRouter } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import BoardsBarEditModal from '../boards-bar-edit-modal';
+import useBoardsBarEditModalStore from '../../../stores/use-boards-bar-edit-modal-store';
+import useBoardsBarVisibilityStore from '../../../stores/use-boards-bar-visibility-store';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise;
+
+vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
+ useAccount: () => ({
+ subscriptions: ['custom.eth'],
+ }),
+}));
+
+let container: HTMLDivElement;
+let root: Root;
+
+const renderModal = async () => {
+ await act(async () => {
+ root.render(createElement(MemoryRouter, { initialEntries: ['/tv/catalog'] }, createElement(BoardsBarEditModal)));
+ });
+};
+
+describe('BoardsBarEditModal', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ useBoardsBarEditModalStore.setState({ showModal: true });
+ useBoardsBarVisibilityStore.setState({
+ visibleDirectories: new Set(['tv']),
+ showSubscriptionsInBoardsBar: false,
+ });
+
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ useBoardsBarEditModalStore.setState({ showModal: false });
+ localStorage.clear();
+ });
+
+ it('keeps the modal open when typing spaces in the directory input', async () => {
+ await renderModal();
+
+ const input = container.querySelector('input[aria-label="Directory codes"]');
+ expect(input).toBeTruthy();
+
+ await act(async () => {
+ input?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
+ });
+
+ expect(container.querySelector('[role="dialog"]')).toBeTruthy();
+ expect(useBoardsBarEditModalStore.getState().showModal).toBe(true);
+ });
+
+ it('still closes when the backdrop itself handles keyboard activation', async () => {
+ await renderModal();
+
+ const backdrop = container.querySelector('[role="button"]');
+ expect(backdrop).toBeTruthy();
+
+ await act(async () => {
+ backdrop?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
+ });
+
+ expect(useBoardsBarEditModalStore.getState().showModal).toBe(false);
+ });
+});
diff --git a/src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx b/src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx
index 4ad8bbf2..c5f84278 100644
--- a/src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx
+++ b/src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx
@@ -12,8 +12,12 @@ const stringToDirectories = (str: string): Set => {
const codes = str
.trim()
.split(/\s+/)
- .filter((code) => code.length > 0)
- .map((code) => code.toLowerCase());
+ .reduce((items, code) => {
+ if (code.length > 0) {
+ items.push(code.toLowerCase());
+ }
+ return items;
+ }, []);
return new Set(codes);
};
@@ -118,7 +122,7 @@ const BoardsBarEditModal = () => {
role='button'
tabIndex={0}
onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
+ if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
closeBoardsBarEditModal();
}
diff --git a/src/components/boards-bar/boards-bar.tsx b/src/components/boards-bar/boards-bar.tsx
index c60cff42..a0b9c11d 100644
--- a/src/components/boards-bar/boards-bar.tsx
+++ b/src/components/boards-bar/boards-bar.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import getShortAddress from '../../lib/get-short-address';
@@ -32,21 +32,18 @@ const SearchBar = ({ setShowSearchBar }: { setShowSearchBar: (show: boolean) =>
searchInputRef.current?.focus();
}, []);
- const handleClickOutside = useCallback(
- (event: MouseEvent) => {
+ useEffect(() => {
+ const closeSearchOnOutsideClick = (event: MouseEvent) => {
if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) {
setShowSearchBar(false);
}
- },
- [searchBarRef, setShowSearchBar],
- );
-
- useEffect(() => {
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
};
- }, [handleClickOutside]);
+
+ document.addEventListener('mousedown', closeSearchOnOutsideClick);
+ return () => {
+ document.removeEventListener('mousedown', closeSearchOnOutsideClick);
+ };
+ }, [setShowSearchBar]);
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
@@ -113,8 +110,7 @@ const BoardsBarDesktop = () => {
return [...(activeAccount?.subscriptions || [])];
},
(prev, next) => {
- if (prev.length !== next.length) return false;
- return prev.every((val, idx) => val === next[idx]);
+ return prev.length === next.length && prev.every((val, idx) => val === next[idx]);
},
);
@@ -146,7 +142,7 @@ const BoardsBarDesktop = () => {
const address = findBoardAddressByCode(code, directories);
const isPlaceholder = !address;
- const handleClick = (e: React.MouseEvent) => {
+ const openDirectoryForPlaceholder = (e: React.MouseEvent) => {
// If no address exists, prevent navigation and open directory modal
if (!address) {
e.preventDefault();
@@ -168,13 +164,13 @@ const BoardsBarDesktop = () => {
if (!address) openDirectoryModal();
}
}}
- onClick={handleClick}
+ onClick={openDirectoryForPlaceholder}
style={{ cursor: 'pointer' }}
>
{code}
) : (
-
+
{code}
)}
diff --git a/src/components/catalog-filters/__tests__/catalog-filters.test.tsx b/src/components/catalog-filters/__tests__/catalog-filters.test.tsx
index 2fecc7b8..805a2c26 100644
--- a/src/components/catalog-filters/__tests__/catalog-filters.test.tsx
+++ b/src/components/catalog-filters/__tests__/catalog-filters.test.tsx
@@ -212,7 +212,7 @@ describe('CatalogFilters', () => {
expect(addedRowInputs[3]?.checked).toBe(false);
});
- it('reorders, edits, and saves non-empty filters via the document Enter shortcut', async () => {
+ it('reorders, edits, and saves non-empty filters from the form', async () => {
renderCatalogFilters();
await openModal();
@@ -255,6 +255,11 @@ describe('CatalogFilters', () => {
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
});
+ expect(testState.saveAndApplyFiltersMock).not.toHaveBeenCalled();
+
+ await act(async () => {
+ container.querySelector('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ });
expect(testState.saveAndApplyFiltersMock).toHaveBeenCalledTimes(1);
const savedFilters = testState.saveAndApplyFiltersMock.mock.calls[0]?.[0] as FilterItem[] | undefined;
diff --git a/src/components/catalog-filters/catalog-filters.tsx b/src/components/catalog-filters/catalog-filters.tsx
index 1dbb9f32..2dbab9a3 100644
--- a/src/components/catalog-filters/catalog-filters.tsx
+++ b/src/components/catalog-filters/catalog-filters.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback, useRef, useEffect } from 'react';
+import { useState, useCallback, useRef, useEffect, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
@@ -91,7 +91,12 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
}, []);
const handleSave = useCallback(() => {
- const nonEmptyFilters = localFilterItems.filter((item) => item.text.trim() !== '').map(({ id: _id, ...rest }) => rest);
+ const nonEmptyFilters = localFilterItems.reduce[]>((filters, item) => {
+ if (item.text.trim() === '') return filters;
+ const { id: _id, ...rest } = item;
+ filters.push(rest);
+ return filters;
+ }, []);
saveAndApplyFilters(nonEmptyFilters);
@@ -104,20 +109,14 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
onSave();
}, [saveAndApplyFilters, localFilterItems, onSave, resetFeed]);
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (e.key === 'Enter') {
- handleSave();
- }
+ const handleSubmit = useCallback(
+ (event: FormEvent) => {
+ event.preventDefault();
+ handleSave();
},
[handleSave],
);
- useEffect(() => {
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [handleKeyDown]);
-
const updateLocalFilterItem = useCallback((index: number, item: any) => {
setLocalFilterItems((prev) => prev.map((f, i) => (i === index ? item : f)));
}, []);
@@ -136,100 +135,104 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
}, []);
return (
-
+
);
};
diff --git a/src/components/catalog-filters/filters-protip/filters-protip.tsx b/src/components/catalog-filters/filters-protip/filters-protip.tsx
index 5ae12370..afe00654 100644
--- a/src/components/catalog-filters/filters-protip/filters-protip.tsx
+++ b/src/components/catalog-filters/filters-protip/filters-protip.tsx
@@ -9,7 +9,7 @@ const FiltersProtip = () => {
Matching whole words:
- feel — will match "feel" but not "feeling" . This search is case-insensitive.
+ feel: will match "feel" but not "feeling" . This search is case-insensitive.
@@ -17,7 +17,7 @@ const FiltersProtip = () => {
AND operator:
- feel girlfriend — will match "feel" AND "girlfriend" in any order.
+ feel girlfriend: will match "feel" AND "girlfriend" in any order.
@@ -25,7 +25,7 @@ const FiltersProtip = () => {
OR operator:
- feel|girlfriend — will match "feel" OR "girlfriend" .
+ feel|girlfriend: will match "feel" OR "girlfriend" .
@@ -33,7 +33,7 @@ const FiltersProtip = () => {
Mixing both operators:
- girlfriend|boyfriend feel — matches "feel" AND "girlfriend" , or "feel" AND "boyfriend" .
+ girlfriend|boyfriend feel: matches "feel" AND "girlfriend" , or "feel" AND "boyfriend" .
@@ -41,7 +41,7 @@ const FiltersProtip = () => {
Exact match search:
- "that feel when" — place double quotes around the pattern to search for an exact string.
+ "that feel when": place double quotes around the pattern to search for an exact string.
@@ -49,10 +49,10 @@ const FiltersProtip = () => {
Wildcards:
- feel* — matches expressions such as "feel" , "feels" , "feeling" , "feeler" , etc…
+ feel*: matches expressions such as "feel" , "feels" , "feeling" , "feeler" , etc…
- idolm*ster — this can match "idolmaster" or "idolm@ster" , etc…
+ idolm*ster: this can match "idolmaster" or "idolm@ster" , etc…
@@ -70,31 +70,31 @@ const FiltersProtip = () => {
It is also possible to filter by regular expression:
- /^(?=.*detachable)(?=.*hats).*$/i — AND operator.
+ /^(?=.*detachable)(?=.*hats).*$/i: AND operator.
- /^(?!.*touhou).*$/i — NOT operator.
+ /^(?!.*touhou).*$/i: NOT operator.
- {'/^>/'} — threads starting with a quote ({'">"'} character as an html entity).
+ {'/^>/'}: threads starting with a quote ({'">"'} character as an html entity).
- /^$/ — threads with no text.
+ /^$/: threads with no text.
Controls
- On — enables or disables the filter.
+ On : enables or disables the filter.
- Color — highlights matched threads with the specified color.
+ Color : highlights matched threads with the specified color.
- Hide — hides matched threads.
+ Hide : hides matched threads.
- Top — moves the filter to the top of the feed.
+ Top : moves the filter to the top of the feed.
diff --git a/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.module.css b/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.module.css
index 38bdf5c1..04f79cac 100644
--- a/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.module.css
+++ b/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.module.css
@@ -51,6 +51,16 @@
cursor: pointer;
}
+.colorPreview {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 1px solid #aaa;
+ vertical-align: middle;
+ margin-left: 5px;
+ cursor: pointer;
+}
+
.middleTxt input[type="text"] {
width: 45px;
margin: 0 2px;
diff --git a/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx b/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx
index 75f93e69..2a5cf0f6 100644
--- a/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx
+++ b/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx
@@ -83,7 +83,7 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
left: 0,
right: 0,
bottom: 0,
- zIndex: 999,
+ zIndex: 30,
}}
role='button'
tabIndex={0}
@@ -144,13 +144,6 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
className={styles.colorPreview}
style={{
backgroundColor: customColor || '#fff',
- display: 'inline-block',
- width: '16px',
- height: '16px',
- border: '1px solid #aaa',
- verticalAlign: 'middle',
- marginLeft: '5px',
- cursor: 'pointer',
}}
role='button'
tabIndex={0}
diff --git a/src/components/catalog-search/catalog-search.tsx b/src/components/catalog-search/catalog-search.tsx
index 25ccd884..af67d03b 100644
--- a/src/components/catalog-search/catalog-search.tsx
+++ b/src/components/catalog-search/catalog-search.tsx
@@ -8,11 +8,11 @@ import debounce from 'lodash/debounce';
const CatalogSearch = () => {
const { t } = useTranslation();
- const location = useLocation();
+ const { pathname, search } = useLocation();
const navigate = useNavigate();
const [searchState, setSearchState] = useState({ open: false, value: '' });
const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore();
- const queryParam = new URLSearchParams(location.search).get('q') ?? '';
+ const queryParam = new URLSearchParams(search).get('q') ?? '';
const openSearch = !!queryParam || searchState.open;
const inputValue = searchState.open || searchState.value ? searchState.value : queryParam;
@@ -27,17 +27,17 @@ const CatalogSearch = () => {
const updateURL = useCallback(
(searchText: string) => {
- const urlParams = new URLSearchParams(location.search);
+ const urlParams = new URLSearchParams(search);
if (searchText.trim()) {
urlParams.set('q', searchText);
} else {
urlParams.delete('q');
}
const newSearch = urlParams.toString();
- const newPath = location.pathname + (newSearch ? `?${newSearch}` : '');
+ const newPath = pathname + (newSearch ? `?${newSearch}` : '');
navigate(newPath, { replace: true });
},
- [location.pathname, location.search, navigate],
+ [pathname, search, navigate],
);
const debouncedSetSearchFilter = useMemo(
diff --git a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx
index d846348a..e0e5fd4e 100644
--- a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx
+++ b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx
@@ -318,7 +318,7 @@ describe('ChallengeModal', () => {
'https://mintpass.org',
);
- await clickButton('Done');
+ await clickButton('Close challenge');
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']);
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
diff --git a/src/components/challenge-modal/challenge-modal.tsx b/src/components/challenge-modal/challenge-modal.tsx
index d85a060d..03bc9390 100644
--- a/src/components/challenge-modal/challenge-modal.tsx
+++ b/src/components/challenge-modal/challenge-modal.tsx
@@ -129,6 +129,8 @@ const IframeChallenge = ({
const attemptedLoadRef = useRef(false);
const mountedRef = useRef(false);
const handledAutoCompleteRef = useRef(false);
+ const onAutoCompleteRef = useRef(onAutoComplete);
+ onAutoCompleteRef.current = onAutoComplete;
const expectedSessionId = getIframeSessionId(challenge);
useEffect(() => {
@@ -239,12 +241,12 @@ const IframeChallenge = ({
const sessionId = (data as { sessionId?: unknown }).sessionId;
if (sessionId !== expectedSessionId) return;
handledAutoCompleteRef.current = true;
- onAutoComplete(challengeAnswers.filter((answer): answer is string => typeof answer === 'string'));
+ onAutoCompleteRef.current(challengeAnswers.filter((answer): answer is string => typeof answer === 'string'));
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
- }, [expectedSessionId, iframeOrigin, onAutoComplete]);
+ }, [expectedSessionId, iframeOrigin]);
if (!iframeUrlState) {
return (
@@ -289,7 +291,7 @@ const IframeChallenge = ({
>
@@ -339,11 +341,9 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
({ active, event, offset: [ox, oy] }) => {
if (active) {
event.preventDefault();
- document.body.style.userSelect = 'none';
- document.body.style.webkitUserSelect = 'none';
+ Object.assign(document.body.style, { userSelect: 'none', webkitUserSelect: 'none' });
} else {
- document.body.style.userSelect = '';
- document.body.style.webkitUserSelect = '';
+ Object.assign(document.body.style, { userSelect: '', webkitUserSelect: '' });
}
api.start({ x: ox, y: oy, immediate: true });
},
@@ -449,21 +449,21 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
const publicationDetails = (
<>
-
+
{title && (
-
+
)}
{content && (
-
+
)}
{link && (
-
+
)}
>
diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx
index 7962304f..daffb2fb 100644
--- a/src/components/comment-content/comment-content.tsx
+++ b/src/components/comment-content/comment-content.tsx
@@ -39,7 +39,7 @@ const useScopedCidToNumber = (cids: string[]) => {
uniqueCids.add(cid);
}
}
- return [...uniqueCids].sort();
+ return Array.from(uniqueCids).toSorted();
}, [cids]);
const cidToNumber = usePostNumberStore(
diff --git a/src/components/create-board-modal/create-board-modal.tsx b/src/components/create-board-modal/create-board-modal.tsx
index 1b5f8b2d..abc877b8 100644
--- a/src/components/create-board-modal/create-board-modal.tsx
+++ b/src/components/create-board-modal/create-board-modal.tsx
@@ -41,7 +41,7 @@ const CreateBoardModal = () => {
bitsocial-cli
. Build a following: Users can subscribe to your board via the "[Subscribe]" button, which adds it to their top bar. You can gain
- subscribers through direct links, word of mouth, or search—no directory assignment or dev approval needed.
+ subscribers through direct links, word of mouth, or search, no directory assignment or dev approval needed.
@@ -67,7 +67,7 @@ const CreateBoardModal = () => {
Decentralization
- Devs can change directories via commits in the open-source repo. No centralized servers—anyone can fork, modify, and redeploy to their own domain. 5chan is
+ Devs can change directories via commits in the open-source repo. No centralized servers; anyone can fork, modify, and redeploy to their own domain. 5chan is
adminless with no central authority.
diff --git a/src/components/directory-modal/directory-modal.tsx b/src/components/directory-modal/directory-modal.tsx
index a56a7b0c..d9f8af6e 100644
--- a/src/components/directory-modal/directory-modal.tsx
+++ b/src/components/directory-modal/directory-modal.tsx
@@ -47,7 +47,7 @@ const DirectoryModal = () => {
bitsocial-cli
- . Users can access it anytime via the search bar, direct links, or by subscribing with the "[Subscribe]" button—
+ . Users can access it anytime via the search bar, direct links, or by subscribing with the "[Subscribe]" button;{' '}
no directory assignment or dev approval needed . Directory boards are simply featured in homepage categories (like "Anime &
Manga") and are handpicked by devs until directory voting is available.
diff --git a/src/components/markdown/__tests__/markdown.test.tsx b/src/components/markdown/__tests__/markdown.test.tsx
index 65610a4d..820c34d8 100644
--- a/src/components/markdown/__tests__/markdown.test.tsx
+++ b/src/components/markdown/__tests__/markdown.test.tsx
@@ -230,6 +230,44 @@ describe('Markdown', () => {
expect(links.find((link) => link.getAttribute('href') === '/fit')?.textContent).toBe('>>>/fit/');
});
+ it('preserves trailing punctuation outside cross-board links', async () => {
+ await renderMarkdown({
+ content: 'see >>>/fit/, next',
+ });
+
+ const link = container.querySelector('a');
+ expect(link?.getAttribute('href')).toBe('/fit');
+ expect(link?.textContent).toBe('>>>/fit/');
+ expect(container.textContent).toBe('see >>>/fit/, next');
+ });
+
+ it('normalizes hash-routed 5chan links before passing them to React Router', async () => {
+ testState.internalPathByHref = {
+ 'https://5chan.local/#/mu': '#/mu',
+ };
+
+ await renderMarkdown({
+ content: 'https://5chan.local/#/mu',
+ });
+
+ const link = container.querySelector('a');
+ expect(link?.getAttribute('href')).toBe('/mu');
+ expect(link?.textContent).toBe('https://5chan.local/#/mu');
+ });
+
+ it('preserves balanced URL parentheses and leaves unmatched trailing punctuation outside links', async () => {
+ await renderMarkdown({
+ content: 'https://en.wikipedia.org/wiki/Function_(mathematics) https://example.com/path),',
+ });
+
+ const links = Array.from(container.querySelectorAll('a'));
+ expect(links[0]?.textContent).toBe('https://en.wikipedia.org/wiki/Function_(mathematics)');
+ expect(links[0]?.getAttribute('href')).toBe('https://en.wikipedia.org/wiki/Function_(mathematics)');
+ expect(links[1]?.textContent).toBe('https://example.com/path');
+ expect(links[1]?.getAttribute('href')).toBe('https://example.com/path');
+ expect(container.textContent).toBe('https://en.wikipedia.org/wiki/Function_(mathematics) https://example.com/path),');
+ });
+
it('renders number quote links with op and unavailable state derived from cached comments', async () => {
testState.comments = {
'comment-42': { cid: 'comment-42', number: 42 },
diff --git a/src/components/markdown/external-number-quote-link.tsx b/src/components/markdown/external-number-quote-link.tsx
index 9f0aaf75..a4b4f4e6 100644
--- a/src/components/markdown/external-number-quote-link.tsx
+++ b/src/components/markdown/external-number-quote-link.tsx
@@ -206,7 +206,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
setPreviewPosition(null);
};
- const handleClick = async (e: MouseEvent) => {
+ const openExternalQuote = async (e: MouseEvent) => {
e.preventDefault();
if (isResolving) {
@@ -273,7 +273,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
aria-busy={isResolving || undefined}
className={isResolving ? styles.inlineQuoteLinkResolving : undefined}
href={`#/${boardLabel}`}
- onClick={handleClick}
+ onClick={openExternalQuote}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={anchorRef}
@@ -289,7 +289,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
className={previewClassName}
data-thread-scroll-preview='true'
ref={previewRef}
- style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 1000 }}
+ style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 30 }}
>
{previewContent}
,
diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx
index 10adf9db..93677d0c 100644
--- a/src/components/markdown/markdown.tsx
+++ b/src/components/markdown/markdown.tsx
@@ -141,23 +141,63 @@ const normalizeContent = (content: string): string => {
};
type Token =
- | { type: 'text'; value: string }
- | { type: 'url'; href: string }
- | { type: 'quoteLink'; number: number }
- | { type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
- | { type: 'crossBoardLink'; display: string; route: string }
- | { type: 'spoiler'; tokens: Token[] };
+ | { key: string; type: 'text'; value: string }
+ | { key: string; type: 'url'; href: string }
+ | { key: string; type: 'quoteLink'; number: number }
+ | { key: string; type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
+ | { key: string; type: 'crossBoardLink'; display: string; route: string }
+ | { key: string; type: 'spoiler'; tokens: Token[] };
const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/;
const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/;
const QUOTE_LINK_REGEX = /(?/\w])>>(\d+)(?![\d/])/;
-const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/;
+const URL_REGEX = /https?:\/\/[^\s<>[\]]+/;
const COMBINED_REGEX = new RegExp(
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
'g',
);
+const makeTokenKey = (prefix: string, type: Token['type'], start: number, end: number): string => `${prefix}${type}:${start}:${end}`;
+
+function normalizeInternalRouteHref(href: string): string {
+ if (href.startsWith('/#/')) {
+ return href.slice(2);
+ }
+ if (href.startsWith('#/')) {
+ return href.slice(1);
+ }
+ return href;
+}
+
+function splitUrlTrailingText(rawHref: string): { href: string; trailingText: string } {
+ let href = rawHref;
+ let trailingText = '';
+
+ while (href) {
+ const trailingPunctuationMatch = href.match(/[.,;:!?"']+$/);
+ if (trailingPunctuationMatch) {
+ trailingText = `${trailingPunctuationMatch[0]}${trailingText}`;
+ href = href.slice(0, -trailingPunctuationMatch[0].length);
+ continue;
+ }
+
+ if (href.endsWith(')')) {
+ const openingParens = (href.match(/\(/g) || []).length;
+ const closingParens = (href.match(/\)/g) || []).length;
+ if (closingParens > openingParens) {
+ trailingText = `)${trailingText}`;
+ href = href.slice(0, -1);
+ continue;
+ }
+ }
+
+ break;
+ }
+
+ return { href, trailingText };
+}
+
function getCrossboardRoute(fullPattern: string): string | null {
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
if (!isValidCrossboardPattern(`>>>/${pathPart}`)) {
@@ -177,7 +217,7 @@ function getCrossboardRoute(fullPattern: string): string | null {
return `/${pathPart}`;
}
-function tokenize(text: string): Token[] {
+function tokenize(text: string, keyPrefix = ''): Token[] {
const tokens: Token[] = [];
let lastIndex = 0;
@@ -187,19 +227,26 @@ function tokenize(text: string): Token[] {
while ((match = regex.exec(text)) !== null) {
const fullMatch = match[0];
const matchStart = match.index;
+ const matchEnd = regex.lastIndex;
if (matchStart > lastIndex) {
- tokens.push({ type: 'text', value: text.slice(lastIndex, matchStart) });
+ tokens.push({
+ key: makeTokenKey(keyPrefix, 'text', lastIndex, matchStart),
+ type: 'text',
+ value: text.slice(lastIndex, matchStart),
+ });
}
if (match[1] !== undefined) {
const innerContent = match[2];
- tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
+ const key = makeTokenKey(keyPrefix, 'spoiler', matchStart, matchEnd);
+ tokens.push({ key, type: 'spoiler', tokens: tokenize(innerContent, `${key}/`) });
} else if (match[3] !== undefined) {
const boardIdentifier = match[4];
const number = parseInt(match[5], 10);
if (boardIdentifier && !Number.isNaN(number)) {
tokens.push({
+ key: makeTokenKey(keyPrefix, 'crossBoardNumberQuoteLink', matchStart, matchEnd),
type: 'crossBoardNumberQuoteLink',
reference: {
boardIdentifier,
@@ -209,29 +256,43 @@ function tokenize(text: string): Token[] {
},
});
} else {
- tokens.push({ type: 'text', value: fullMatch });
+ tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
}
} else if (match[6] !== undefined) {
const pathPart = match[7];
const fullPattern = `>>>/${pathPart}`;
const route = getCrossboardRoute(fullPattern);
if (route) {
- tokens.push({ type: 'crossBoardLink', display: fullPattern, route });
+ const trailingText = fullMatch.startsWith(fullPattern) ? fullMatch.slice(fullPattern.length) : '';
+ const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
+ tokens.push({ key: makeTokenKey(keyPrefix, 'crossBoardLink', matchStart, linkEnd), type: 'crossBoardLink', display: fullPattern, route });
+ if (trailingText) {
+ tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText });
+ }
} else {
- tokens.push({ type: 'text', value: fullMatch });
+ tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
}
} else if (match[8] !== undefined) {
const number = parseInt(match[9], 10);
- tokens.push({ type: 'quoteLink', number });
+ tokens.push({ key: makeTokenKey(keyPrefix, 'quoteLink', matchStart, matchEnd), type: 'quoteLink', number });
} else if (match[10] !== undefined) {
- tokens.push({ type: 'url', href: fullMatch });
+ const { href, trailingText } = splitUrlTrailingText(fullMatch);
+ const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
+ tokens.push({ key: makeTokenKey(keyPrefix, 'url', matchStart, linkEnd), type: 'url', href });
+ if (trailingText) {
+ tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText });
+ }
}
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
- tokens.push({ type: 'text', value: text.slice(lastIndex) });
+ tokens.push({
+ key: makeTokenKey(keyPrefix, 'text', lastIndex, text.length),
+ type: 'text',
+ value: text.slice(lastIndex),
+ });
}
return tokens;
@@ -243,54 +304,6 @@ interface RenderContext {
communityAddress?: string;
}
-function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[] {
- const { isInCatalogView, postCid, communityAddress } = context;
-
- return tokens.map((token, i) => {
- switch (token.type) {
- case 'text':
- return {token.value} ;
- case 'url': {
- const href = token.href;
- const linkMediaInfo = getLinkMediaInfo(href);
- const embedUrl = safeParseUrl(href);
- if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) {
- return (
-
- {href}
-
- );
- }
- return {renderAnchorLink(href, href, postCid, communityAddress)} ;
- }
- case 'quoteLink':
- return (
-
-
-
- );
- case 'crossBoardNumberQuoteLink':
- return (
-
-
-
- );
- case 'crossBoardLink':
- return (
-
- {token.display}
-
- );
- case 'spoiler':
- return (
-
- {renderTokens(token.tokens, context)}
-
- );
- }
- });
-}
-
interface MarkdownProps {
content: string;
title?: string;
@@ -329,38 +342,30 @@ const NumberQuoteLink = ({ number, threadPostCid, communityAddress }: { number:
return ;
};
-const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string, communityAddress?: string) => {
+const AnchorLink = ({ href, text }: { href: string; text: string }) => {
if (!href) {
- return {children} ;
+ return {text} ;
}
if (is5chanLink(href)) {
const internalPath = transform5chanLinkToInternal(href);
if (internalPath) {
- let shouldReplaceText = false;
-
- if (typeof children === 'string') {
- shouldReplaceText = children === href || children.trim() === href.trim();
- } else if (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string') {
- shouldReplaceText = children[0] === href || children[0].trim() === href.trim();
- }
-
- let displayText: React.ReactNode = children;
- const childrenText = typeof children === 'string' ? children : Array.isArray(children) ? children[0] : '';
- const isAutolinkedUrl = shouldReplaceText && typeof childrenText === 'string' && childrenText.startsWith('http');
+ const internalRoute = normalizeInternalRouteHref(internalPath);
+ let displayText: React.ReactNode = text;
+ const isAutolinkedUrl = text.startsWith('http');
if (isAutolinkedUrl) {
- displayText = children;
- } else if (shouldReplaceText && internalPath.match(/^\/[^/]+$/)) {
- displayText = internalPath.substring(1);
- } else if (shouldReplaceText) {
- displayText = internalPath;
+ displayText = text;
+ } else if (internalRoute.match(/^\/[^/]+$/)) {
+ displayText = internalRoute.substring(1);
+ } else {
+ displayText = internalRoute;
}
- return {displayText};
+ return {displayText};
} else {
console.warn('Failed to transform 5chan link to internal path:', href);
- return {children};
+ return {text};
}
}
@@ -372,16 +377,68 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) ||
href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
) {
- return {children};
+ return {text};
}
return (
- {children}
+ {text}
);
};
+const TokenNode = ({ token, context }: { token: Token; context: RenderContext }) => {
+ const { isInCatalogView, postCid, communityAddress } = context;
+
+ switch (token.type) {
+ case 'text':
+ return <>{token.value}>;
+ case 'url': {
+ const href = token.href;
+ const linkMediaInfo = getLinkMediaInfo(href);
+ const embedUrl = safeParseUrl(href);
+ if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) {
+ return (
+
+ {href}
+
+ );
+ }
+ return ;
+ }
+ case 'quoteLink':
+ return (
+
+
+
+ );
+ case 'crossBoardNumberQuoteLink':
+ return (
+
+
+
+ );
+ case 'crossBoardLink':
+ return {token.display};
+ case 'spoiler':
+ return (
+
+
+
+ );
+ }
+};
+
+const TokenList = ({ tokens, context }: { tokens: Token[]; context: RenderContext }) => {
+ return (
+ <>
+ {tokens.map((token) => (
+
+ ))}
+ >
+ );
+};
+
const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => {
const location = useLocation();
const params = useParams();
@@ -392,6 +449,8 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
const lines = normalized.split('\n');
const elements: React.ReactNode[] = [];
+ const context = { isInCatalogView, postCid, communityAddress };
+
lines.forEach((line, lineIndex) => {
if (lineIndex > 0) {
elements.push( );
@@ -402,7 +461,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
const isGreentext = /^>[^>]/.test(line) || line === '>';
const tokens = tokenize(line);
- const lineElements = renderTokens(tokens, { isInCatalogView, postCid, communityAddress });
+ const lineElements = ;
if (isGreentext) {
elements.push(
diff --git a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx
index 37f9b3af..40b73927 100644
--- a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx
+++ b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx
@@ -46,7 +46,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
const { t } = useTranslation();
const directories = useDirectories();
const boardIdentifier = getBoardPath(communityAddress, directories);
- const handleClick = async () => {
+ const copyDirectLink = async () => {
await safeCopyShareLink(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
onClose();
};
@@ -55,11 +55,11 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
className={styles.postMenuItem}
role='button'
tabIndex={0}
- onClick={handleClick}
+ onClick={copyDirectLink}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ copyDirectLink();
}
}}
>
@@ -70,7 +70,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
const { t } = useTranslation();
- const handleClick = async () => {
+ const copyContentId = async () => {
await safeCopyToClipboard(cid, 'content id');
onClose();
};
@@ -79,11 +79,11 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
className={styles.postMenuItem}
role='button'
tabIndex={0}
- onClick={handleClick}
+ onClick={copyContentId}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ copyContentId();
}
}}
>
@@ -94,7 +94,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
const { t } = useTranslation();
- const handleClick = async () => {
+ const copyUserId = async () => {
await safeCopyToClipboard(address, 'user id');
onClose();
};
@@ -103,11 +103,11 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
className={styles.postMenuItem}
role='button'
tabIndex={0}
- onClick={handleClick}
+ onClick={copyUserId}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ copyUserId();
}
}}
>
diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx
index 3126ec2b..10a7ed3c 100644
--- a/src/components/post-form/post-form.tsx
+++ b/src/components/post-form/post-form.tsx
@@ -286,13 +286,13 @@ const PostFormFields = ({
setPublishPostOptions({ communityAddress: e.target.value })} value={communityAddress}>
{t('choose_one')}
{isInAllView &&
- directories
- .filter((community) => community.title && community.address)
- .map((community) => (
+ directories.map((community) =>
+ community.title && community.address ? (
{community.title}
- ))}
+ ) : null,
+ )}
{isInModView &&
accountCommunityAddresses.map((address: string) => (
diff --git a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx
index 18882219..3d9b89b7 100644
--- a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx
+++ b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx
@@ -64,7 +64,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
const { t } = useTranslation();
const directories = useDirectories();
const boardIdentifier = getBoardPath(communityAddress, directories);
- const handleClick = async () => {
+ const copyDirectLink = async () => {
await copyShareLinkSafe(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
onClose();
};
@@ -72,11 +72,11 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
{
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ copyDirectLink();
}
}}
>
@@ -87,7 +87,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
const { t } = useTranslation();
- const handleClick = async () => {
+ const copyContentId = async () => {
await copyContentIdSafe(cid);
onClose();
};
@@ -95,11 +95,11 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
{
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ copyContentId();
}
}}
>
@@ -110,7 +110,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
const { t } = useTranslation();
- const handleClick = async () => {
+ const copyUserId = async () => {
await copyUserIdSafe(address);
onClose();
};
@@ -118,11 +118,11 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
{
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ copyUserId();
}
}}
>
@@ -163,7 +163,7 @@ const { addChallenge } = useChallengesStore.getState();
const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
- const handleClick = () => {
+ const reportPost = () => {
alert("Reporting isn't available yet.");
onClose();
};
@@ -171,11 +171,11 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
{
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ reportPost();
}
}}
>
@@ -235,7 +235,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
const { publishCommentEdit } = usePublishCommentEdit(deleteOptions);
- const handleClick = async () => {
+ const deletePost = async () => {
const confirmed = window.confirm(t('delete_post_confirm'));
if (!confirmed) {
return;
@@ -254,11 +254,11 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
{
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ deletePost();
}
}}
>
@@ -272,7 +272,7 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
const { hide, hidden, unhide } = useHide({ cid: cid || '' });
const isInPostView = isPostPageView(useLocation().pathname, useParams());
- const handleClick = () => {
+ const togglePostHidden = () => {
if (hidden) {
unhide();
} else {
@@ -285,11 +285,11 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
{
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ togglePostHidden();
}
}}
>
diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx
index 1cbe315a..cde760af 100644
--- a/src/components/reply-modal/__tests__/reply-modal.test.tsx
+++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx
@@ -32,6 +32,7 @@ const testState = vi.hoisted(() => ({
quoteInsertNumber: undefined as number | undefined,
quoteInsertRequestId: 0,
quoteInsertSelectedText: '',
+ dragHandler: undefined as ((state: { active: boolean; event: Pick; offset: [number, number] }) => void) | undefined,
replyIndex: undefined as number | undefined,
resetPublishReplyOptionsMock: vi.fn(),
resolvedCommunityAddress: undefined as string | undefined,
@@ -208,7 +209,10 @@ vi.mock('@react-spring/web', async () => {
});
vi.mock('@use-gesture/react', () => ({
- useDrag: () => () => ({}),
+ useDrag: (handler: (state: { active: boolean; event: Pick; offset: [number, number] }) => void) => {
+ testState.dragHandler = handler;
+ return () => ({});
+ },
}));
let container: HTMLDivElement;
@@ -292,6 +296,7 @@ describe('ReplyModal', () => {
testState.quoteInsertNumber = undefined;
testState.quoteInsertRequestId = 0;
testState.quoteInsertSelectedText = '';
+ testState.dragHandler = undefined;
testState.replyIndex = undefined;
testState.resetPublishReplyOptionsMock.mockReset();
testState.resolvedCommunityAddress = undefined;
@@ -326,6 +331,8 @@ describe('ReplyModal', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
+ document.body.style.userSelect = '';
+ document.body.style.webkitUserSelect = '';
});
it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => {
@@ -493,6 +500,41 @@ describe('ReplyModal', () => {
expect(modal?.style.touchAction).toBe('none');
});
+ it('closes with Escape from the document on desktop', async () => {
+ await renderReplyModal('/mu/thread/post-1');
+
+ await act(async () => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
+ });
+
+ expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('restores body selection styles if unmounted during a drag', async () => {
+ document.body.style.userSelect = 'text';
+ document.body.style.webkitUserSelect = 'auto';
+
+ await renderReplyModal('/mu/thread/post-1');
+
+ await act(async () => {
+ testState.dragHandler?.({
+ active: true,
+ event: { preventDefault: vi.fn() },
+ offset: [140, 100],
+ });
+ });
+
+ expect(document.body.style.userSelect).toBe('none');
+ expect(document.body.style.webkitUserSelect).toBe('none');
+
+ await act(async () => {
+ root.render(createElement(React.Fragment));
+ });
+
+ expect(document.body.style.userSelect).toBe('text');
+ expect(document.body.style.webkitUserSelect).toBe('auto');
+ });
+
it('initializes the drag spring once so typing rerenders do not recenter the modal', async () => {
await renderReplyModal('/mu/thread/post-1');
diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx
index c538c371..4e9217f5 100644
--- a/src/components/reply-modal/reply-modal.tsx
+++ b/src/components/reply-modal/reply-modal.tsx
@@ -54,6 +54,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const account = useAccount();
const { displayName } = account?.author || {};
const textRef = useRef(null);
+ const setTextRef = useRef((element: HTMLTextAreaElement | null) => {
+ textRef.current = element;
+ if (!element) return;
+
+ window.setTimeout(() => {
+ if (textRef.current === element) {
+ element.focus();
+ }
+ }, 0);
+ });
const urlRef = useRef(null);
const lastSelectionStartRef = useRef(0);
const lastSelectionEndRef = useRef(0);
@@ -126,6 +136,27 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
[],
);
+ const bodySelectionStyleBeforeDragRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null);
+
+ const disableBodyTextSelection = () => {
+ if (!bodySelectionStyleBeforeDragRef.current) {
+ bodySelectionStyleBeforeDragRef.current = {
+ userSelect: document.body.style.userSelect,
+ webkitUserSelect: document.body.style.webkitUserSelect,
+ };
+ }
+ Object.assign(document.body.style, { userSelect: 'none', webkitUserSelect: 'none' });
+ };
+
+ const restoreBodyTextSelection = () => {
+ const previousStyle = bodySelectionStyleBeforeDragRef.current;
+ Object.assign(document.body.style, {
+ userSelect: previousStyle?.userSelect ?? '',
+ webkitUserSelect: previousStyle?.webkitUserSelect ?? '',
+ });
+ bodySelectionStyleBeforeDragRef.current = null;
+ };
+
const bind = useDrag(
({ active, event, offset: [ox, oy] }) => {
const nextLeft = Math.round(ox);
@@ -133,11 +164,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
if (active) {
event.preventDefault();
- document.body.style.userSelect = 'none';
- document.body.style.webkitUserSelect = 'none';
+ disableBodyTextSelection();
} else {
- document.body.style.userSelect = '';
- document.body.style.webkitUserSelect = '';
+ restoreBodyTextSelection();
}
api.start({ left: nextLeft, top: nextTop, immediate: true });
},
@@ -148,6 +177,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
},
);
+ useEffect(() => {
+ return () => {
+ restoreBodyTextSelection();
+ };
+ }, []);
+
useEffect(() => {
if (nodeRef.current && isMobile) {
const viewportHeight = window.innerHeight;
@@ -158,6 +193,21 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const parentCidRef = useRef(null);
+ useEffect(() => {
+ if (!showReplyModal || isMobile) {
+ return;
+ }
+
+ const closeReplyModalOnEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ closeModal();
+ }
+ };
+
+ document.addEventListener('keydown', closeReplyModalOnEscape);
+ return () => document.removeEventListener('keydown', closeReplyModalOnEscape);
+ }, [showReplyModal, isMobile, closeModal]);
+
useEffect(() => {
if (parentCidRef.current) {
const cidWidth = parentCidRef.current.offsetWidth;
@@ -165,29 +215,6 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}
}, [parentCid]);
- useEffect(() => {
- if (showReplyModal) {
- setTimeout(() => {
- if (textRef.current) {
- textRef.current.focus();
- }
- }, 0);
-
- if (!isMobile) {
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- closeModal();
- }
- };
- document.addEventListener('keydown', handleEscape);
-
- return () => {
- document.removeEventListener('keydown', handleEscape);
- };
- }
- }
- }, [showReplyModal, closeModal, isMobile]);
-
useEffect(() => {
if (textRef.current) {
const len = textRef.current.value.length;
@@ -209,11 +236,15 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
setPublishReplyOptions({ content });
checkContentLengthRef.current(content, t);
- setTimeout(() => {
+ const spellcheckTimeout = window.setTimeout(() => {
if (textRef.current) {
textRef.current.spellcheck = true;
}
}, 100);
+
+ return () => {
+ window.clearTimeout(spellcheckTimeout);
+ };
}
}, [showReplyModal, openEmpty, defaultParentQuote, selectedText]);
@@ -351,7 +382,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
cols={48}
rows={4}
wrap='soft'
- ref={textRef}
+ ref={setTextRef.current}
aria-label={t('comment')}
spellCheck={true}
onInput={handleContentInput}
diff --git a/src/components/settings-modal/account-settings/account-settings.tsx b/src/components/settings-modal/account-settings/account-settings.tsx
index 25feabb7..98406d6f 100644
--- a/src/components/settings-modal/account-settings/account-settings.tsx
+++ b/src/components/settings-modal/account-settings/account-settings.tsx
@@ -152,9 +152,11 @@ const AccountSettingsEditor = ({
accountData.account.subscriptions = [];
}
const uniqueSubscriptions = [...accountData.account.subscriptions];
+ const knownSubscriptions = new Set(uniqueSubscriptions);
for (const address of communityAddresses) {
- if (!uniqueSubscriptions.includes(address)) {
+ if (!knownSubscriptions.has(address)) {
uniqueSubscriptions.push(address);
+ knownSubscriptions.add(address);
}
}
accountData.account.subscriptions = uniqueSubscriptions;
diff --git a/src/components/settings-modal/advanced-settings/advanced-settings.tsx b/src/components/settings-modal/advanced-settings/advanced-settings.tsx
index 7155d37b..321c6600 100644
--- a/src/components/settings-modal/advanced-settings/advanced-settings.tsx
+++ b/src/components/settings-modal/advanced-settings/advanced-settings.tsx
@@ -219,6 +219,14 @@ const PureP2PBrowserSettings = ({ pureP2PBrowserRef }: SettingsProps) => {
const isElectron = window.electronApi?.isElectron === true;
+const getTrimmedLines = (value: string | undefined): string[] | undefined => {
+ return value?.split('\n').reduce((lines, line) => {
+ const trimmedLine = line.trim();
+ if (trimmedLine) lines.push(trimmedLine);
+ return lines;
+ }, []);
+};
+
const AdvancedSettings = () => {
const { t } = useTranslation();
const account = useAccount() as AccountShape | undefined;
@@ -234,27 +242,15 @@ const AdvancedSettings = () => {
const pureP2PBrowserRef = useRef(null);
const handleSave = async () => {
- const ipfsGatewayUrls = ipfsGatewayUrlsRef.current?.value
- .split('\n')
- .map((url) => url.trim())
- .filter((url) => url !== '');
+ const ipfsGatewayUrls = getTrimmedLines(ipfsGatewayUrlsRef.current?.value);
const mediaIpfsGatewayUrl = mediaIpfsGatewayUrlRef.current?.value.trim();
- const pubsubKuboRpcClientsOptions = pubsubProvidersRef.current?.value
- .split('\n')
- .map((url) => url.trim())
- .filter((url) => url !== '');
+ const pubsubKuboRpcClientsOptions = getTrimmedLines(pubsubProvidersRef.current?.value);
- const ethRpcUrls = ethRpcRef.current?.value
- .split('\n')
- .map((url) => url.trim())
- .filter((url) => url !== '');
+ const ethRpcUrls = getTrimmedLines(ethRpcRef.current?.value);
- const httpRoutersOptions = httpRoutersRef.current?.value
- .split('\n')
- .map((url) => url.trim())
- .filter((url) => url !== '');
+ const httpRoutersOptions = getTrimmedLines(httpRoutersRef.current?.value);
const pkcRpcClientsOptions = p2pRpcRef.current?.value.trim() ? [p2pRpcRef.current.value.trim()] : undefined;
const dataPath = p2pDataPathRef.current?.value.trim() || undefined;
diff --git a/src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx b/src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx
index 3c93cb70..2a41fb3e 100644
--- a/src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx
+++ b/src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx
@@ -177,7 +177,7 @@ const CryptoWalletsForm = ({ account }: { account: Account | undefined }) => {
{
const newIndex = walletsArray.length;
- setWalletsArray([...walletsArray, defaultWalletObject]);
+ setWalletsArray((currentWallets) => [...currentWallets, defaultWalletObject]);
setSelectedWallet(newIndex);
}}
>
diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx
index f9ab8a9a..72f4fde4 100644
--- a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx
+++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx
@@ -254,11 +254,10 @@ const getBrowserTransferStats = async (client?: Libp2pClientShape): Promise getMetricSnapshot(source)));
+ const metricStats = metricSnapshots
+ .map((snapshot) => getTransferStatsFromMetricSnapshot(snapshot))
+ .reduce((stats, nextStats) => mergeTransferStats(stats, nextStats), {});
return mergeTransferStats(counterStats, metricStats);
} catch {
diff --git a/src/components/settings-modal/settings-modal.tsx b/src/components/settings-modal/settings-modal.tsx
index 551b46d3..50badf9a 100644
--- a/src/components/settings-modal/settings-modal.tsx
+++ b/src/components/settings-modal/settings-modal.tsx
@@ -32,16 +32,16 @@ const hashToSection = (hash: string, sectionIds = allSectionIds): string | null
const SettingsModal = () => {
const { t } = useTranslation();
const account = useAccount();
- const location = useLocation();
+ const { hash: locationHash, pathname } = useLocation();
const navigate = useNavigate();
- const hash = location.hash.slice(1);
+ const hash = locationHash.slice(1);
const sectionIds = useMemo(() => (shouldShowP2PSettingsSection(account) ? [...allSectionIds, P2P_STATS_SECTION_ID] : allSectionIds), [account]);
const hashSection = hashToSection(hash, sectionIds);
const closeModal = useCallback(() => {
- const newPath = location.pathname.replace(/\/settings$/, '');
+ const newPath = pathname.replace(/\/settings$/, '');
navigate(newPath);
- }, [location.pathname, navigate]);
+ }, [pathname, navigate]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@@ -77,7 +77,7 @@ const SettingsModal = () => {
const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]);
- const basePath = location.pathname;
+ const basePath = pathname;
const handleCategoryClick = (categoryId: string) => {
const isOpening = !visibleExpandedSections.has(categoryId);
diff --git a/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx b/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx
index 7c05a4ca..50e09bc6 100644
--- a/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx
+++ b/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx
@@ -9,7 +9,7 @@ const SubscriptionButton = ({ address }: { address: string }) => {
const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address });
const [recentlyUnsubscribed, setRecentlyUnsubscribed] = useState(false);
- const handleClick = () => {
+ const toggleSubscription = () => {
if (recentlyUnsubscribed || !subscribed) {
subscribe();
setRecentlyUnsubscribed(false);
@@ -29,10 +29,10 @@ const SubscriptionButton = ({ address }: { address: string }) => {
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- handleClick();
+ toggleSubscription();
}
}}
- onClick={handleClick}
+ onClick={toggleSubscription}
>
{recentlyUnsubscribed || !subscribed ? t('subscribe') : t('unsubscribe')}
diff --git a/src/e2e/pretext-benchmark-harness.tsx b/src/e2e/pretext-benchmark-harness.tsx
index f4e9b63a..14fa7e8c 100644
--- a/src/e2e/pretext-benchmark-harness.tsx
+++ b/src/e2e/pretext-benchmark-harness.tsx
@@ -195,11 +195,13 @@ const buildReplies = (count: number, seed: number): SyntheticReply[] => {
for (let index = 0; index < count; index += 1) {
const quotedTargets: string[] = [];
+ const quotedTargetSet = new Set();
const quoteTargetCount = replies.length === 0 ? 0 : Math.floor(random() * 3);
for (let quoteIndex = 0; quoteIndex < quoteTargetCount; quoteIndex += 1) {
const targetReply = replies[Math.floor(random() * replies.length)];
- if (targetReply?.cid && !quotedTargets.includes(targetReply.cid)) {
+ if (targetReply?.cid && !quotedTargetSet.has(targetReply.cid)) {
quotedTargets.push(targetReply.cid);
+ quotedTargetSet.add(targetReply.cid);
}
}
@@ -323,9 +325,11 @@ const buildBoardItems = (count: number, seed: number): SyntheticBoardItem[] => {
for (let replyIndex = 0; replyIndex < previewReplyCount; replyIndex += 1) {
const quotedCids = random() < 0.45 ? [cid] : [];
+ const quotedCidSet = new Set(quotedCids);
const priorReply = replyIndex > 0 && random() < 0.3 ? previewReplies[Math.floor(random() * replyIndex)] : undefined;
- if (priorReply?.cid && !quotedCids.includes(priorReply.cid)) {
+ if (priorReply?.cid && !quotedCidSet.has(priorReply.cid)) {
quotedCids.push(priorReply.cid);
+ quotedCidSet.add(priorReply.cid);
}
const replyText = `${quotedCids.map((quotedCid) => `>>${quotedCid.split('-').at(-1)}`).join(' ')} ${buildText(random, replyIndex)}`.trim();
@@ -551,7 +555,7 @@ const Harness = () => {
if (currentHeightEstimates.length === 0) {
return undefined;
}
- const sortedEstimates = [...currentHeightEstimates].sort((leftValue, rightValue) => leftValue - rightValue);
+ const sortedEstimates = currentHeightEstimates.slice().sort((leftValue, rightValue) => leftValue - rightValue);
return sortedEstimates[Math.floor(sortedEstimates.length / 2)];
}, [catalogImageSize, catalogRowHeightEstimates, currentHeightEstimates, surface]);
diff --git a/src/hooks/use-community-identifiers.ts b/src/hooks/use-community-identifiers.ts
index fb7def50..26957391 100644
--- a/src/hooks/use-community-identifiers.ts
+++ b/src/hooks/use-community-identifiers.ts
@@ -4,7 +4,7 @@ import { findDirectoryByAddress, type DirectoryCommunity, useDirectories } from
const isLikelyCommunityName = (value: string) => value.includes('.');
-export const getCommunityIdentifier = (communityAddress: string | undefined, directories: DirectoryCommunity[]): CommunityIdentifier | undefined => {
+const getCommunityIdentifier = (communityAddress: string | undefined, directories: DirectoryCommunity[]): CommunityIdentifier | undefined => {
if (!communityAddress) {
return undefined;
}
@@ -36,7 +36,7 @@ export const getCommunityIdentifier = (communityAddress: string | undefined, dir
};
};
-export const getCommunityIdentifiers = (communityAddresses: Array, directories: DirectoryCommunity[]): CommunityIdentifier[] =>
+const getCommunityIdentifiers = (communityAddresses: Array, directories: DirectoryCommunity[]): CommunityIdentifier[] =>
communityAddresses.flatMap((communityAddress) => {
const community = getCommunityIdentifier(communityAddress, directories);
return community ? [community] : [];
diff --git a/src/hooks/use-directories.ts b/src/hooks/use-directories.ts
index 022107a9..a76505fc 100644
--- a/src/hooks/use-directories.ts
+++ b/src/hooks/use-directories.ts
@@ -8,7 +8,7 @@ interface DirectoriesMetadata {
updatedAt: number;
}
-export interface DirectoryFeatures {
+interface DirectoryFeatures {
postsPerPage?: number;
pseudonymityMode?: string;
nsfw?: boolean;
diff --git a/src/lib/utils/blotter-utils.ts b/src/lib/utils/blotter-utils.ts
index ddc81070..e97a5ae8 100644
--- a/src/lib/utils/blotter-utils.ts
+++ b/src/lib/utils/blotter-utils.ts
@@ -3,7 +3,7 @@
* Board preview count is fixed at 3 entries to preserve compact block height.
*/
-export type BlotterEntryKind = 'release' | 'manual';
+type BlotterEntryKind = 'release' | 'manual';
export interface BlotterEntry {
id: string;
diff --git a/src/lib/utils/pretext-height-estimates.ts b/src/lib/utils/pretext-height-estimates.ts
index bc39e0c6..44792c7e 100644
--- a/src/lib/utils/pretext-height-estimates.ts
+++ b/src/lib/utils/pretext-height-estimates.ts
@@ -69,7 +69,7 @@ const COMMENT_TOO_LONG_NOTICE = 'Comment too long. Click here to view the full t
const DEFAULT_REPLY_VIRTUALIZATION_MODE: ReplyVirtualizationMode = 'item-size';
const REPLY_VIRTUALIZATION_MODES: ReplyVirtualizationMode[] = ['off', 'estimates', 'item-size'];
-export const REPLY_HEIGHT_DATA_ATTRIBUTE = 'data-pretext-height';
+const REPLY_HEIGHT_DATA_ATTRIBUTE = 'data-pretext-height';
type PreparedText = ReturnType;
type PreparedTextWithSegments = ReturnType;
@@ -744,7 +744,7 @@ export const getFeedPostHeightEstimate = ({
return desktopResult;
};
-export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
+const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
if (!canUsePretext() || !post) {
return DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP;
}
@@ -778,7 +778,7 @@ export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPC
return estimatedHeight;
};
-export const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => {
+const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => {
const cacheKey =
row.length > 0 ? `${getCatalogEstimateCachePrefix(imageSize, metrics, showOPComment)}\u0000row\u0000${row.map((post) => post?.cid || '').join(',')}` : undefined;
const cachedHeight = cacheKey ? catalogRowHeightEstimateCache.get(cacheKey) : undefined;
diff --git a/src/lib/utils/route-utils.ts b/src/lib/utils/route-utils.ts
index c36a9f76..e6b499a6 100644
--- a/src/lib/utils/route-utils.ts
+++ b/src/lib/utils/route-utils.ts
@@ -124,7 +124,7 @@ export const isDirectoryBoard = (identifier: string, communities: DirectoryCommu
export const isArchiveRoute = (pathname: string): boolean => {
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
- return /\/archive$/.test(normalizedPath);
+ return normalizedPath.endsWith('/archive');
};
export const isFeedRoute = (pathname: string): boolean => {
diff --git a/src/lib/utils/view-utils.ts b/src/lib/utils/view-utils.ts
index 1ac10c0a..49416d1d 100644
--- a/src/lib/utils/view-utils.ts
+++ b/src/lib/utils/view-utils.ts
@@ -78,7 +78,7 @@ export const isSettingsView = (pathname: string, params: ParamsType): boolean =>
);
};
-export const isSubscriptionsView = (pathname: string, params: ParamsType): boolean => {
+export const isSubscriptionsView = (pathname: string, _params: ParamsType): boolean => {
return pathname === '/subs' || pathname === '/subs/settings' || pathname === '/subs/catalog' || pathname === '/subs/catalog/settings';
};
diff --git a/src/views/archive/__tests__/helpers.ts b/src/views/archive/__tests__/helpers.ts
index ff7b2767..fba0c9b8 100644
--- a/src/views/archive/__tests__/helpers.ts
+++ b/src/views/archive/__tests__/helpers.ts
@@ -1,6 +1,6 @@
import * as React from 'react';
import { createElement } from 'react';
-import { createRoot, type Root } from 'react-dom/client';
+import type { Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
diff --git a/src/views/archive/index.ts b/src/views/archive/index.ts
deleted file mode 100644
index 0d0839ba..00000000
--- a/src/views/archive/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './archive';
diff --git a/src/views/home/boards-list/boards-filter-modal.tsx b/src/views/home/boards-list/boards-filter-modal.tsx
index 815485b9..e86e2700 100644
--- a/src/views/home/boards-list/boards-filter-modal.tsx
+++ b/src/views/home/boards-list/boards-filter-modal.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useBoardsFilterStore from '../../../stores/use-boards-filter-store';
import { DISCLAIMER_ACCEPTED_KEY } from '../../../stores/use-disclaimer-modal-store';
@@ -23,21 +23,18 @@ const BoardsFilterModal = () => {
const disclaimerAccepted = hasAcceptedDisclaimer();
- const handleClickOutside = useCallback(
- (event: MouseEvent) => {
+ useEffect(() => {
+ const closeFilterModalOnOutsideClick = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setShowFilterModal(false);
}
- },
- [modalRef, buttonRef, setShowFilterModal],
- );
-
- useEffect(() => {
- document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
};
- }, [handleClickOutside]);
+
+ document.addEventListener('mousedown', closeFilterModalOnOutsideClick);
+ return () => {
+ document.removeEventListener('mousedown', closeFilterModalOnOutsideClick);
+ };
+ }, []);
return (
<>
diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx
index 55296ed2..c673e9cd 100644
--- a/src/views/mod-queue/mod-queue.tsx
+++ b/src/views/mod-queue/mod-queue.tsx
@@ -511,7 +511,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
No. {number ?? 'N/A'}
{showBoard && boardPath && (
<>
- —
+ -
{modQueueUrl ? /{boardDisplayPath}/ : /{boardDisplayPath}/ }
>
)}
@@ -588,6 +588,27 @@ const findBoardAddressByCode = (code: string, dirs: DirectoryCommunity[]): strin
return entry?.address || null;
};
+const ModQueueBoardCount = ({ normal, urgent }: { normal: number; urgent: number }) => {
+ const total = normal + urgent;
+ if (total === 0) return null;
+ return (
+
+ (
+ {urgent > 0 && normal > 0 ? (
+ <>
+ {normal}
+ +{urgent}
+ >
+ ) : urgent > 0 ? (
+ {urgent}
+ ) : (
+ {total}
+ )}
+ )
+
+ );
+};
+
const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: ModQueueBoardSummaryProps) => {
const { t } = useTranslation();
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
@@ -666,32 +687,16 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }:
return null;
}
- const renderCount = (normal: number, urgent: number) => {
- const total = normal + urgent;
- if (total === 0) return null;
- return (
-
- (
- {urgent > 0 && normal > 0 ? (
- <>
- {normal}
- +{urgent}
- >
- ) : urgent > 0 ? (
- {urgent}
- ) : (
- {total}
- )}
- )
-
- );
- };
-
return (
{t('all')}
- {totalNormal + totalUrgent > 0 && <> {renderCount(totalNormal, totalUrgent)}>}
+ {totalNormal + totalUrgent > 0 && (
+ <>
+ {' '}
+
+ >
+ )}
{orderedAddresses.map((address) => {
const boardPath = getBoardPath(address, directories);
@@ -709,7 +714,12 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }:
onClick={() => handleSelectBoard(address)}
>
{displayText}
- {normal + urgent > 0 && <> {renderCount(normal, urgent)}>}
+ {normal + urgent > 0 && (
+ <>
+ {' '}
+
+ >
+ )}
);
@@ -905,7 +915,10 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
}, [queuedCommentSnapshots, rememberCommentsInQueue]);
const feedWithHistory = useMemo(() => {
- const liveCids = new Set(feed.map((comment) => comment.cid).filter(Boolean));
+ const liveCids = feed.reduce>((cids, comment) => {
+ if (comment.cid) cids.add(comment.cid);
+ return cids;
+ }, new Set());
return [
...feed,
...(queuedCommentHistory.filter((comment) => {
diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx
index 52acbca8..668567b4 100644
--- a/src/views/post/post.tsx
+++ b/src/views/post/post.tsx
@@ -54,7 +54,7 @@ const getEffectiveRouteUserState = (state: unknown): QueuedCommentRouteState | u
return getRouteUserState(window.history.state);
};
-export interface ReplyPaginationOverride {
+interface ReplyPaginationOverride {
hasMore?: boolean;
loadMore?: () => void;
replies: Comment[];
@@ -273,7 +273,7 @@ export const Post = memo(
const PostPage = () => {
const { t } = useTranslation();
const params = useParams();
- const location = useLocation();
+ const { key: locationKey, pathname, state: locationState } = useLocation();
const { commentCid } = params;
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId);
@@ -281,8 +281,8 @@ const PostPage = () => {
const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate);
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
const resolvedCommunityAddress = useResolvedCommunityAddress();
- const isInAllView = isAllView(location.pathname);
- const routeState = useMemo(() => getEffectiveRouteUserState(location.state), [location.key, location.pathname, location.state]);
+ const isInAllView = isAllView(pathname);
+ const routeState = useMemo(() => getEffectiveRouteUserState(locationState), [locationKey, pathname, locationState]);
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled });
const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]);
@@ -314,7 +314,7 @@ const PostPage = () => {
// These two effects split normal opens from explicit OP-top intents:
// the first keeps ordinary thread visits on `window.scrollTo(0, 0)`, while the
- // second consumes `requestedThreadTopCid` once per `location.key` via
+ // second consumes `requestedThreadTopCid` once per `locationKey` via
// `consumedThreadTopScrollRef` so `scrollThreadContainerToTop(commentCid)` only
// replays for deliberate OP-link clicks and never for route-driven thread opens.
useEffect(() => {
@@ -327,13 +327,13 @@ const PostPage = () => {
if (!commentCid || post?.cid !== commentCid) return;
if (requestedThreadTopCid !== commentCid) return;
- const consumedKey = `${location.key}:${commentCid}`;
+ const consumedKey = `${locationKey}:${commentCid}`;
if (consumedThreadTopScrollRef.current === consumedKey) return;
if (scrollThreadContainerToTop(commentCid)) {
consumedThreadTopScrollRef.current = consumedKey;
}
- }, [commentCid, location.key, post?.cid, requestedThreadTopCid]);
+ }, [commentCid, locationKey, post?.cid, requestedThreadTopCid]);
useEffect(() => {
const boardIdentifier = params.boardIdentifier;
@@ -412,8 +412,7 @@ const PostPage = () => {
let cancelled = false;
startUpdate();
- void (async () => {
- const results = await Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh()));
+ void Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh())).then((results) => {
if (cancelled) return;
const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled');
@@ -423,7 +422,7 @@ const PostPage = () => {
if (rejectedResult?.status === 'rejected') {
console.error('Failed to refresh thread comments:', rejectedResult.reason);
}
- })();
+ });
return () => {
cancelled = true;