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
This commit is contained in:
Tommaso Casaburi
2026-05-10 18:25:13 +07:00
committed by GitHub
parent e7a6c377c9
commit 556973a445
47 changed files with 771 additions and 442 deletions
+21 -1
View File
@@ -96,6 +96,18 @@ jobs:
if: github.event_name == 'pull_request' && steps.react-ui-changes.outputs.changed != 'true' 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." 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 - name: Install Chromium for smoke tests
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
@@ -111,7 +123,7 @@ jobs:
if-no-files-found: warn if-no-files-found: warn
publish-coverage-badge: publish-coverage-badge:
name: Publish Coverage Badge name: Publish Badges
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: quality needs: quality
if: github.event_name == 'push' && github.ref == 'refs/heads/master' if: github.event_name == 'push' && github.ref == 'refs/heads/master'
@@ -132,13 +144,21 @@ jobs:
name: coverage-badge name: coverage-badge
path: ${{ runner.temp }}/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 - name: Prepare Pages artifact
env: env:
BADGE_SOURCE_PATH: ${{ runner.temp }}/coverage-badge/coverage.json 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 PAGES_OUTPUT_PATH: ${{ runner.temp }}/github-pages
run: | run: |
mkdir -p "${PAGES_OUTPUT_PATH}/badges" mkdir -p "${PAGES_OUTPUT_PATH}/badges"
cp "${BADGE_SOURCE_PATH}" "${PAGES_OUTPUT_PATH}/badges/coverage.json" 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" touch "${PAGES_OUTPUT_PATH}/.nojekyll"
- name: Upload Pages artifact - name: Upload Pages artifact
+1
View File
@@ -1,5 +1,6 @@
[![Build Status](https://img.shields.io/github/actions/workflow/status/bitsocialnet/5chan/ci.yml?branch=master)](https://github.com/bitsocialnet/5chan/actions/workflows/ci.yml) [![Build Status](https://img.shields.io/github/actions/workflow/status/bitsocialnet/5chan/ci.yml?branch=master)](https://github.com/bitsocialnet/5chan/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/endpoint?url=https://bitsocialnet.github.io/5chan/badges/coverage.json)](https://github.com/bitsocialnet/5chan/blob/master/scripts/write-coverage-badge.mjs) [![Coverage](https://img.shields.io/endpoint?url=https://bitsocialnet.github.io/5chan/badges/coverage.json)](https://github.com/bitsocialnet/5chan/blob/master/scripts/write-coverage-badge.mjs)
[![React Doctor](https://img.shields.io/endpoint?url=https://bitsocialnet.github.io/5chan/badges/react-doctor.json)](https://github.com/bitsocialnet/5chan/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/bitsocialnet/5chan)](https://github.com/bitsocialnet/5chan/releases/latest) [![Release](https://img.shields.io/github/v/release/bitsocialnet/5chan)](https://github.com/bitsocialnet/5chan/releases/latest)
[![License](https://img.shields.io/badge/license-GPL--3.0--or--later-red.svg)](https://github.com/bitsocialnet/5chan/blob/master/LICENSE) [![License](https://img.shields.io/badge/license-GPL--3.0--or--later-red.svg)](https://github.com/bitsocialnet/5chan/blob/master/LICENSE)
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
+1
View File
@@ -102,6 +102,7 @@
"doctor": "react-doctor . -y", "doctor": "react-doctor . -y",
"doctor:score": "react-doctor . --score -y", "doctor:score": "react-doctor . --score -y",
"doctor:verbose": "react-doctor . --verbose -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", "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", "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", "test:coverage": "vitest run --coverage.enabled --coverage.provider=istanbul --coverage.reporter=text --coverage.reporter=json --coverage.reporter=json-summary --coverage.reportsDirectory=./coverage",
+44
View File
@@ -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.`);
+18 -20
View File
@@ -67,22 +67,22 @@ preloadReplyModal();
const BoardLayout = () => { const BoardLayout = () => {
const params = useParams(); const params = useParams();
const { accountCommentIndex, boardIdentifier, pageNumber } = params; const { accountCommentIndex, boardIdentifier, pageNumber } = params;
const location = useLocation(); const { pathname, search } = useLocation();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isInAllView = isAllView(location.pathname); const isInAllView = isAllView(pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const isInSubscriptionsView = isSubscriptionsView(pathname, useParams());
const isInModView = isModView(location.pathname); const isInModView = isModView(pathname);
const directories = useDirectories(); const directories = useDirectories();
const communityAddress = boardIdentifier ? getCommunityAddress(boardIdentifier, directories) : undefined; const communityAddress = boardIdentifier ? getCommunityAddress(boardIdentifier, directories) : undefined;
const pendingPost = useSafeAccountComment({ commentIndex: accountCommentIndex }); const pendingPost = useSafeAccountComment({ commentIndex: accountCommentIndex });
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost); const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
const { closeCreateBoardModal } = useCreateBoardModalStore(); const { closeCreateBoardModal } = useCreateBoardModalStore();
const isOnPostRoute = isPostRoute(location.pathname); const isOnPostRoute = isPostRoute(pathname);
const isOnPendingPostRoute = isPendingPostRoute(location.pathname); const isOnPendingPostRoute = isPendingPostRoute(pathname);
const isOnModQueueRoute = isModQueueRoute(location.pathname); const isOnModQueueRoute = isModQueueRoute(pathname);
const isOnArchiveRoute = isArchiveRoute(location.pathname); const isOnArchiveRoute = isArchiveRoute(pathname);
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute; const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute;
const isInCatalogView = isCatalogView(location.pathname, params); const isInCatalogView = isCatalogView(pathname, params);
// Christmas theme // Christmas theme
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore(); const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
useEffect(() => { useEffect(() => {
@@ -97,28 +97,26 @@ const BoardLayout = () => {
// Close create board modal when navigating to a different page // Close create board modal when navigating to a different page
useEffect(() => { useEffect(() => {
closeCreateBoardModal(); closeCreateBoardModal();
}, [location.pathname, closeCreateBoardModal]); }, [pathname, closeCreateBoardModal]);
// force rerender of post form when navigating between pages, except when opening settings modal in current view // force rerender of post form when navigating between pages, except when opening settings modal in current view
const key = location.pathname.endsWith('/settings') const key = pathname.endsWith('/settings') ? `${communityAddress}-${pathname.replace(/\/settings$/, '')}` : `${communityAddress}-${pathname}`;
? `${communityAddress}-${location.pathname.replace(/\/settings$/, '')}`
: `${communityAddress}-${location.pathname}`;
if (pageNumber === '1') { if (pageNumber === '1') {
return <Navigate to='/not-found' replace />; return <Navigate to='/not-found' replace />;
} }
// Invalid /mod/ paths (e.g. /mod/modqueue, /mod/asdoijasd) -> not-found // 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 <Navigate to='/not-found' replace />; return <Navigate to='/not-found' replace />;
} }
if (isLegacyBoardModQueueRoute(location.pathname)) { if (isLegacyBoardModQueueRoute(pathname)) {
return <Navigate to='/not-found' replace />; return <Navigate to='/not-found' replace />;
} }
// Invalid board-scoped mod paths (e.g. /biz/mod, /biz/mod/asdoijasd) -> not-found // 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 <Navigate to='/not-found' replace />; return <Navigate to='/not-found' replace />;
} }
@@ -126,8 +124,8 @@ const BoardLayout = () => {
if (boardIdentifier && !isDirectoryBoard(boardIdentifier, directories)) { if (boardIdentifier && !isDirectoryBoard(boardIdentifier, directories)) {
const canonicalBoardIdentifier = getBoardPath(boardIdentifier, directories); const canonicalBoardIdentifier = getBoardPath(boardIdentifier, directories);
if (canonicalBoardIdentifier !== boardIdentifier) { if (canonicalBoardIdentifier !== boardIdentifier) {
const canonicalPath = location.pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`); const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
return <Navigate to={canonicalPath + (location.search || '')} replace />; return <Navigate to={canonicalPath + (search || '')} replace />;
} }
} }
@@ -201,8 +199,8 @@ const GlobalLayout = () => {
})), })),
); );
const location = useLocation(); const { pathname } = useLocation();
const isInSettingsView = location.pathname.endsWith('/settings'); const isInSettingsView = pathname.endsWith('/settings');
return ( return (
<> <>
@@ -221,11 +221,11 @@ export const AutoButton = () => {
export const BottomButton = () => { export const BottomButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleClick = () => { const scrollToBottom = () => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }); window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' });
}; };
return ( return (
<button className='button' onClick={handleClick}> <button className='button' onClick={scrollToBottom}>
{t('bottom')} {t('bottom')}
</button> </button>
); );
@@ -233,11 +233,11 @@ export const BottomButton = () => {
export const TopButton = () => { export const TopButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleClick = () => { const scrollToTop = () => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}; };
return ( return (
<button className='button' onClick={handleClick}> <button className='button' onClick={scrollToTop}>
{t('top')} {t('top')}
</button> </button>
); );
@@ -507,13 +507,13 @@ export const MobileBoardButtons = () => {
{searchText ? ( {searchText ? (
<span className={styles.filteredThreadsCount}> <span className={styles.filteredThreadsCount}>
{' '} {' '}
{t('search_results_for')}: <strong>{searchText}</strong> - {t('search_results_for')}: <strong>{searchText}</strong>
</span> </span>
) : ( ) : (
filteredCount > 0 && ( filteredCount > 0 && (
<span className={styles.filteredThreadsCount}> <span className={styles.filteredThreadsCount}>
{' '} {' '}
{t('filtered_threads')}: <strong>{filteredCount}</strong> - {t('filtered_threads')}: <strong>{filteredCount}</strong>
</span> </span>
) )
)} )}
@@ -714,14 +714,14 @@ export const DesktopBoardButtons = () => {
{isInCatalogView && searchText ? ( {isInCatalogView && searchText ? (
<span className={styles.filteredThreadsCount}> <span className={styles.filteredThreadsCount}>
{' '} {' '}
{t('search_results_for')}: <strong>{searchText}</strong> - {t('search_results_for')}: <strong>{searchText}</strong>
</span> </span>
) : ( ) : (
isInCatalogView && isInCatalogView &&
filteredCount > 0 && ( filteredCount > 0 && (
<span className={styles.filteredThreadsCount}> <span className={styles.filteredThreadsCount}>
{' '} {' '}
{t('filtered_threads')}: <strong>{filteredCount}</strong> - {t('filtered_threads')}: <strong>{filteredCount}</strong>
</span> </span>
) )
)} )}
@@ -62,7 +62,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
selector({ selector({
accounts: { accounts: {
active: { active: {
subscriptions: new Array(testState.subscriptionsCount).fill('sub'), subscriptions: Array.from({ length: testState.subscriptionsCount }, () => 'sub'),
}, },
}, },
activeAccountId: 'active', activeAccountId: 'active',
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
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<HTMLInputElement>('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<HTMLElement>('[role="button"]');
expect(backdrop).toBeTruthy();
await act(async () => {
backdrop?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
});
expect(useBoardsBarEditModalStore.getState().showModal).toBe(false);
});
});
@@ -12,8 +12,12 @@ const stringToDirectories = (str: string): Set<string> => {
const codes = str const codes = str
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter((code) => code.length > 0) .reduce<string[]>((items, code) => {
.map((code) => code.toLowerCase()); if (code.length > 0) {
items.push(code.toLowerCase());
}
return items;
}, []);
return new Set(codes); return new Set(codes);
}; };
@@ -118,7 +122,7 @@ const BoardsBarEditModal = () => {
role='button' role='button'
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault(); e.preventDefault();
closeBoardsBarEditModal(); closeBoardsBarEditModal();
} }
+13 -17
View File
@@ -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 { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import getShortAddress from '../../lib/get-short-address'; import getShortAddress from '../../lib/get-short-address';
@@ -32,21 +32,18 @@ const SearchBar = ({ setShowSearchBar }: { setShowSearchBar: (show: boolean) =>
searchInputRef.current?.focus(); searchInputRef.current?.focus();
}, []); }, []);
const handleClickOutside = useCallback( useEffect(() => {
(event: MouseEvent) => { const closeSearchOnOutsideClick = (event: MouseEvent) => {
if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) { if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) {
setShowSearchBar(false); 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(() => { useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => { const handleEscapeKey = (event: KeyboardEvent) => {
@@ -113,8 +110,7 @@ const BoardsBarDesktop = () => {
return [...(activeAccount?.subscriptions || [])]; return [...(activeAccount?.subscriptions || [])];
}, },
(prev, next) => { (prev, next) => {
if (prev.length !== next.length) return false; return prev.length === next.length && prev.every((val, idx) => val === next[idx]);
return prev.every((val, idx) => val === next[idx]);
}, },
); );
@@ -146,7 +142,7 @@ const BoardsBarDesktop = () => {
const address = findBoardAddressByCode(code, directories); const address = findBoardAddressByCode(code, directories);
const isPlaceholder = !address; const isPlaceholder = !address;
const handleClick = (e: React.MouseEvent) => { const openDirectoryForPlaceholder = (e: React.MouseEvent) => {
// If no address exists, prevent navigation and open directory modal // If no address exists, prevent navigation and open directory modal
if (!address) { if (!address) {
e.preventDefault(); e.preventDefault();
@@ -168,13 +164,13 @@ const BoardsBarDesktop = () => {
if (!address) openDirectoryModal(); if (!address) openDirectoryModal();
} }
}} }}
onClick={handleClick} onClick={openDirectoryForPlaceholder}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{code} {code}
</span> </span>
) : ( ) : (
<Link to={`/${code}${isInCatalogView ? '/catalog' : ''}`} onClick={handleClick}> <Link to={`/${code}${isInCatalogView ? '/catalog' : ''}`} onClick={openDirectoryForPlaceholder}>
{code} {code}
</Link> </Link>
)} )}
@@ -212,7 +212,7 @@ describe('CatalogFilters', () => {
expect(addedRowInputs[3]?.checked).toBe(false); 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(); renderCatalogFilters();
await openModal(); await openModal();
@@ -255,6 +255,11 @@ describe('CatalogFilters', () => {
await act(async () => { await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); 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); expect(testState.saveAndApplyFiltersMock).toHaveBeenCalledTimes(1);
const savedFilters = testState.saveAndApplyFiltersMock.mock.calls[0]?.[0] as FilterItem[] | undefined; const savedFilters = testState.saveAndApplyFiltersMock.mock.calls[0]?.[0] as FilterItem[] | undefined;
+107 -104
View File
@@ -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 { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
@@ -91,7 +91,12 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
}, []); }, []);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
const nonEmptyFilters = localFilterItems.filter((item) => item.text.trim() !== '').map(({ id: _id, ...rest }) => rest); const nonEmptyFilters = localFilterItems.reduce<Omit<CatalogFilterItemStore, 'id'>[]>((filters, item) => {
if (item.text.trim() === '') return filters;
const { id: _id, ...rest } = item;
filters.push(rest);
return filters;
}, []);
saveAndApplyFilters(nonEmptyFilters); saveAndApplyFilters(nonEmptyFilters);
@@ -104,20 +109,14 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
onSave(); onSave();
}, [saveAndApplyFilters, localFilterItems, onSave, resetFeed]); }, [saveAndApplyFilters, localFilterItems, onSave, resetFeed]);
const handleKeyDown = useCallback( const handleSubmit = useCallback(
(e: KeyboardEvent) => { (event: FormEvent<HTMLFormElement>) => {
if (e.key === 'Enter') { event.preventDefault();
handleSave(); handleSave();
}
}, },
[handleSave], [handleSave],
); );
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const updateLocalFilterItem = useCallback((index: number, item: any) => { const updateLocalFilterItem = useCallback((index: number, item: any) => {
setLocalFilterItems((prev) => prev.map((f, i) => (i === index ? item : f))); setLocalFilterItems((prev) => prev.map((f, i) => (i === index ? item : f)));
}, []); }, []);
@@ -136,100 +135,104 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
}, []); }, []);
return ( return (
<table className={styles.filtersTable}> <form onSubmit={handleSubmit}>
<thead> <table className={styles.filtersTable}>
<tr> <thead>
<th>order</th> <tr>
<th>on</th> <th>order</th>
<th>pattern</th> <th>on</th>
<th>color</th> <th>pattern</th>
<th>hide</th> <th>color</th>
<th>top</th> <th>hide</th>
<th>del</th> <th>top</th>
</tr> <th>del</th>
</thead> </tr>
<tbody> </thead>
{localFilterItems.map((item, index) => ( <tbody>
<tr key={item.id ?? index}> {localFilterItems.map((item, index) => (
<td> <tr key={item.id ?? index}>
<span <td>
className={styles.orderButton} <span
role='button' className={styles.orderButton}
tabIndex={0} role='button'
onKeyDown={(e) => { tabIndex={0}
if (e.key === 'Enter' || e.key === ' ') { onKeyDown={(e) => {
e.preventDefault(); if (e.key === 'Enter' || e.key === ' ') {
moveLocalFilterItemUp(index); e.preventDefault();
} moveLocalFilterItemUp(index);
}} }
onClick={() => moveLocalFilterItemUp(index)} }}
> onClick={() => moveLocalFilterItemUp(index)}
>
</span>
</td> </span>
<td> </td>
<input <td>
type='checkbox' <input
className={styles.onCheckbox} type='checkbox'
checked={item.enabled} className={styles.onCheckbox}
onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })} checked={item.enabled}
/> onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })}
</td> />
<td> </td>
<input <td>
type='text' <input
autoCorrect='off' type='text'
autoComplete='off' autoCorrect='off'
spellCheck='false' autoComplete='off'
value={item.text} spellCheck='false'
onChange={(e) => updateLocalFilterItem(index, { ...item, text: e.target.value })} value={item.text}
ref={(el) => (inputRefs.current[index] = el)} onChange={(e) => updateLocalFilterItem(index, { ...item, text: e.target.value })}
/> ref={(el) => {
</td> inputRefs.current[index] = el;
<td> }}
<HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} /> />
</td> </td>
<td> <td>
<input type='checkbox' checked={item.hide} onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })} /> <HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} />
</td> </td>
<td> <td>
<input type='checkbox' checked={item.top} onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })} /> <input type='checkbox' checked={item.hide} onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })} />
</td> </td>
<td> <td>
<span <input type='checkbox' checked={item.top} onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })} />
className={styles.deleteButton} </td>
role='button' <td>
tabIndex={0} <span
onKeyDown={(e) => { className={styles.deleteButton}
if (e.key === 'Enter' || e.key === ' ') { role='button'
e.preventDefault(); tabIndex={0}
removeLocalFilterItem(index); onKeyDown={(e) => {
} if (e.key === 'Enter' || e.key === ' ') {
}} e.preventDefault();
onClick={() => removeLocalFilterItem(index)} removeLocalFilterItem(index);
> }
× }}
</span> onClick={() => removeLocalFilterItem(index)}
</td> >
<td className={styles.filterHits}> ×
{currentCommunityAddress && item.communityFilteredCids?.has(currentCommunityAddress) && `x${item.communityCounts?.get(currentCommunityAddress) ?? 0}`} </span>
</td>
<td className={styles.filterHits}>
{currentCommunityAddress && item.communityFilteredCids?.has(currentCommunityAddress) && `x${item.communityCounts?.get(currentCommunityAddress) ?? 0}`}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={7}>
<button type='button' className={styles.addButton} onClick={handleAddFilter}>
{t('add')}
</button>
<button type='submit' className={styles.saveButton}>
{t('save')}
</button>
</td> </td>
</tr> </tr>
))} </tfoot>
</tbody> </table>
<tfoot> </form>
<tr>
<td colSpan={7}>
<button className={styles.addButton} onClick={handleAddFilter}>
{t('add')}
</button>
<button className={styles.saveButton} onClick={handleSave}>
{t('save')}
</button>
</td>
</tr>
</tfoot>
</table>
); );
}; };
@@ -9,7 +9,7 @@ const FiltersProtip = () => {
<strong>Matching whole words:</strong> <strong>Matching whole words:</strong>
</li> </li>
<li> <li>
<code>feel</code> will match <em>"feel"</em> but not <em>"feeling"</em>. This search is case-insensitive. <code>feel</code>: will match <em>"feel"</em> but not <em>"feeling"</em>. This search is case-insensitive.
</li> </li>
</ul> </ul>
<ul> <ul>
@@ -17,7 +17,7 @@ const FiltersProtip = () => {
<strong>AND operator:</strong> <strong>AND operator:</strong>
</li> </li>
<li> <li>
<code>feel girlfriend</code> will match <em>"feel"</em> AND <em>"girlfriend"</em> in any order. <code>feel girlfriend</code>: will match <em>"feel"</em> AND <em>"girlfriend"</em> in any order.
</li> </li>
</ul> </ul>
<ul> <ul>
@@ -25,7 +25,7 @@ const FiltersProtip = () => {
<strong>OR operator:</strong> <strong>OR operator:</strong>
</li> </li>
<li> <li>
<code>feel|girlfriend</code> will match <em>"feel"</em> OR <em>"girlfriend"</em>. <code>feel|girlfriend</code>: will match <em>"feel"</em> OR <em>"girlfriend"</em>.
</li> </li>
</ul> </ul>
<ul> <ul>
@@ -33,7 +33,7 @@ const FiltersProtip = () => {
<strong>Mixing both operators:</strong> <strong>Mixing both operators:</strong>
</li> </li>
<li> <li>
<code>girlfriend|boyfriend feel</code> matches <em>"feel"</em> AND <em>"girlfriend"</em>, or <em>"feel"</em> AND <em>"boyfriend"</em>. <code>girlfriend|boyfriend feel</code>: matches <em>"feel"</em> AND <em>"girlfriend"</em>, or <em>"feel"</em> AND <em>"boyfriend"</em>.
</li> </li>
</ul> </ul>
<ul> <ul>
@@ -41,7 +41,7 @@ const FiltersProtip = () => {
<strong>Exact match search:</strong> <strong>Exact match search:</strong>
</li> </li>
<li> <li>
<code>"that feel when"</code> place double quotes around the pattern to search for an exact string. <code>"that feel when"</code>: place double quotes around the pattern to search for an exact string.
</li> </li>
</ul> </ul>
<ul> <ul>
@@ -49,10 +49,10 @@ const FiltersProtip = () => {
<strong>Wildcards:</strong> <strong>Wildcards:</strong>
</li> </li>
<li> <li>
<code>feel*</code> matches expressions such as <em>"feel"</em>, <em>"feels"</em>, <em>"feeling"</em>, <em>"feeler"</em>, etc <code>feel*</code>: matches expressions such as <em>"feel"</em>, <em>"feels"</em>, <em>"feeling"</em>, <em>"feeler"</em>, etc
</li> </li>
<li> <li>
<code>idolm*ster</code> this can match <em>"idolmaster"</em> or <em>"idolm@ster"</em>, etc <code>idolm*ster</code>: this can match <em>"idolmaster"</em> or <em>"idolm@ster"</em>, etc
</li> </li>
</ul> </ul>
<ul> <ul>
@@ -70,31 +70,31 @@ const FiltersProtip = () => {
<ul> <ul>
<strong>It is also possible to filter by regular expression:</strong> <strong>It is also possible to filter by regular expression:</strong>
<li> <li>
<code>/^(?=.*detachable)(?=.*hats).*$/i</code> AND operator. <code>/^(?=.*detachable)(?=.*hats).*$/i</code>: AND operator.
</li> </li>
<li> <li>
<code>/^(?!.*touhou).*$/i</code> NOT operator. <code>/^(?!.*touhou).*$/i</code>: NOT operator.
</li> </li>
<li> <li>
<code>{'/^&gt;/'}</code> threads starting with a quote (<em>{'">"'}</em> character as an html entity). <code>{'/^&gt;/'}</code>: threads starting with a quote (<em>{'">"'}</em> character as an html entity).
</li> </li>
<li> <li>
<code>/^$/</code> threads with no text. <code>/^$/</code>: threads with no text.
</li> </li>
</ul> </ul>
<h4>Controls</h4> <h4>Controls</h4>
<ul> <ul>
<li> <li>
<strong>On</strong> enables or disables the filter. <strong>On</strong>: enables or disables the filter.
</li> </li>
<li> <li>
<strong>Color</strong> highlights matched threads with the specified color. <strong>Color</strong>: highlights matched threads with the specified color.
</li> </li>
<li> <li>
<strong>Hide</strong> hides matched threads. <strong>Hide</strong>: hides matched threads.
</li> </li>
<li> <li>
<strong>Top</strong> moves the filter to the top of the feed. <strong>Top</strong>: moves the filter to the top of the feed.
</li> </li>
</ul> </ul>
</div> </div>
@@ -51,6 +51,16 @@
cursor: pointer; 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"] { .middleTxt input[type="text"] {
width: 45px; width: 45px;
margin: 0 2px; margin: 0 2px;
@@ -83,7 +83,7 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
zIndex: 999, zIndex: 30,
}} }}
role='button' role='button'
tabIndex={0} tabIndex={0}
@@ -144,13 +144,6 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
className={styles.colorPreview} className={styles.colorPreview}
style={{ style={{
backgroundColor: customColor || '#fff', backgroundColor: customColor || '#fff',
display: 'inline-block',
width: '16px',
height: '16px',
border: '1px solid #aaa',
verticalAlign: 'middle',
marginLeft: '5px',
cursor: 'pointer',
}} }}
role='button' role='button'
tabIndex={0} tabIndex={0}
@@ -8,11 +8,11 @@ import debounce from 'lodash/debounce';
const CatalogSearch = () => { const CatalogSearch = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const { pathname, search } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchState, setSearchState] = useState({ open: false, value: '' }); const [searchState, setSearchState] = useState({ open: false, value: '' });
const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore(); 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 openSearch = !!queryParam || searchState.open;
const inputValue = searchState.open || searchState.value ? searchState.value : queryParam; const inputValue = searchState.open || searchState.value ? searchState.value : queryParam;
@@ -27,17 +27,17 @@ const CatalogSearch = () => {
const updateURL = useCallback( const updateURL = useCallback(
(searchText: string) => { (searchText: string) => {
const urlParams = new URLSearchParams(location.search); const urlParams = new URLSearchParams(search);
if (searchText.trim()) { if (searchText.trim()) {
urlParams.set('q', searchText); urlParams.set('q', searchText);
} else { } else {
urlParams.delete('q'); urlParams.delete('q');
} }
const newSearch = urlParams.toString(); const newSearch = urlParams.toString();
const newPath = location.pathname + (newSearch ? `?${newSearch}` : ''); const newPath = pathname + (newSearch ? `?${newSearch}` : '');
navigate(newPath, { replace: true }); navigate(newPath, { replace: true });
}, },
[location.pathname, location.search, navigate], [pathname, search, navigate],
); );
const debouncedSetSearchFilter = useMemo( const debouncedSetSearchFilter = useMemo(
@@ -318,7 +318,7 @@ describe('ChallengeModal', () => {
'https://mintpass.org', 'https://mintpass.org',
); );
await clickButton('Done'); await clickButton('Close challenge');
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']); expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']);
expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
}); });
@@ -129,6 +129,8 @@ const IframeChallenge = ({
const attemptedLoadRef = useRef(false); const attemptedLoadRef = useRef(false);
const mountedRef = useRef(false); const mountedRef = useRef(false);
const handledAutoCompleteRef = useRef(false); const handledAutoCompleteRef = useRef(false);
const onAutoCompleteRef = useRef(onAutoComplete);
onAutoCompleteRef.current = onAutoComplete;
const expectedSessionId = getIframeSessionId(challenge); const expectedSessionId = getIframeSessionId(challenge);
useEffect(() => { useEffect(() => {
@@ -239,12 +241,12 @@ const IframeChallenge = ({
const sessionId = (data as { sessionId?: unknown }).sessionId; const sessionId = (data as { sessionId?: unknown }).sessionId;
if (sessionId !== expectedSessionId) return; if (sessionId !== expectedSessionId) return;
handledAutoCompleteRef.current = true; 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); window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage);
}, [expectedSessionId, iframeOrigin, onAutoComplete]); }, [expectedSessionId, iframeOrigin]);
if (!iframeUrlState) { if (!iframeUrlState) {
return ( return (
@@ -289,7 +291,7 @@ const IframeChallenge = ({
</div> </div>
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}> <div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
<div className={styles.iframeCloseButton}> <div className={styles.iframeCloseButton}>
<button onClick={onDone}>Done</button> <button onClick={onDone}>Close challenge</button>
</div> </div>
</div> </div>
</> </>
@@ -339,11 +341,9 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
({ active, event, offset: [ox, oy] }) => { ({ active, event, offset: [ox, oy] }) => {
if (active) { if (active) {
event.preventDefault(); event.preventDefault();
document.body.style.userSelect = 'none'; Object.assign(document.body.style, { userSelect: 'none', webkitUserSelect: 'none' });
document.body.style.webkitUserSelect = 'none';
} else { } else {
document.body.style.userSelect = ''; Object.assign(document.body.style, { userSelect: '', webkitUserSelect: '' });
document.body.style.webkitUserSelect = '';
} }
api.start({ x: ox, y: oy, immediate: true }); api.start({ x: ox, y: oy, immediate: true });
}, },
@@ -449,21 +449,21 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
const publicationDetails = ( const publicationDetails = (
<> <>
<div className={styles.name}> <div className={styles.name}>
<input type='text' value={displayName || capitalize(t('anonymous'))} disabled /> <input type='text' value={displayName || capitalize(t('anonymous'))} disabled readOnly />
</div> </div>
{title && ( {title && (
<div className={styles.subject}> <div className={styles.subject}>
<input type='text' value={title} disabled /> <input type='text' value={title} disabled readOnly />
</div> </div>
)} )}
{content && ( {content && (
<div className={styles.content}> <div className={styles.content}>
<textarea value={content} disabled cols={48} rows={4} wrap='soft' /> <textarea value={content} disabled readOnly cols={48} rows={4} wrap='soft' />
</div> </div>
)} )}
{link && ( {link && (
<div className={styles.link}> <div className={styles.link}>
<input type='text' value={link} disabled /> <input type='text' value={link} disabled readOnly />
</div> </div>
)} )}
</> </>
@@ -39,7 +39,7 @@ const useScopedCidToNumber = (cids: string[]) => {
uniqueCids.add(cid); uniqueCids.add(cid);
} }
} }
return [...uniqueCids].sort(); return Array.from(uniqueCids).toSorted();
}, [cids]); }, [cids]);
const cidToNumber = usePostNumberStore( const cidToNumber = usePostNumberStore(
@@ -41,7 +41,7 @@ const CreateBoardModal = () => {
bitsocial-cli bitsocial-cli
</a> </a>
. <strong>Build a following:</strong> Users can subscribe to your board via the &quot;[Subscribe]&quot; button, which adds it to their top bar. You can gain . <strong>Build a following:</strong> Users can subscribe to your board via the &quot;[Subscribe]&quot; button, which adds it to their top bar. You can gain
subscribers through direct links, word of mouth, or searchno directory assignment or dev approval needed. subscribers through direct links, word of mouth, or search, no directory assignment or dev approval needed.
</p> </p>
</div> </div>
@@ -67,7 +67,7 @@ const CreateBoardModal = () => {
<div className={styles.section}> <div className={styles.section}>
<h3>Decentralization</h3> <h3>Decentralization</h3>
<p> <p>
Devs can change directories via commits in the open-source repo. No centralized serversanyone 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. adminless with no central authority.
</p> </p>
</div> </div>
@@ -47,7 +47,7 @@ const DirectoryModal = () => {
<a href='https://github.com/bitsocialnet/bitsocial-cli' target='_blank' rel='noopener noreferrer'> <a href='https://github.com/bitsocialnet/bitsocial-cli' target='_blank' rel='noopener noreferrer'>
bitsocial-cli bitsocial-cli
</a> </a>
. Users can access it anytime via the search bar, direct links, or by subscribing with the &quot;[Subscribe]&quot; button . Users can access it anytime via the search bar, direct links, or by subscribing with the &quot;[Subscribe]&quot; button;{' '}
<strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like &quot;Anime & <strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like &quot;Anime &
Manga&quot;) and are handpicked by devs until directory voting is available. Manga&quot;) and are handpicked by devs until directory voting is available.
</p> </p>
@@ -230,6 +230,44 @@ describe('Markdown', () => {
expect(links.find((link) => link.getAttribute('href') === '/fit')?.textContent).toBe('>>>/fit/'); 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 () => { it('renders number quote links with op and unavailable state derived from cached comments', async () => {
testState.comments = { testState.comments = {
'comment-42': { cid: 'comment-42', number: 42 }, 'comment-42': { cid: 'comment-42', number: 42 },
@@ -206,7 +206,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
setPreviewPosition(null); setPreviewPosition(null);
}; };
const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => { const openExternalQuote = async (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault(); e.preventDefault();
if (isResolving) { if (isResolving) {
@@ -273,7 +273,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
aria-busy={isResolving || undefined} aria-busy={isResolving || undefined}
className={isResolving ? styles.inlineQuoteLinkResolving : undefined} className={isResolving ? styles.inlineQuoteLinkResolving : undefined}
href={`#/${boardLabel}`} href={`#/${boardLabel}`}
onClick={handleClick} onClick={openExternalQuote}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
ref={anchorRef} ref={anchorRef}
@@ -289,7 +289,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
className={previewClassName} className={previewClassName}
data-thread-scroll-preview='true' data-thread-scroll-preview='true'
ref={previewRef} 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} {previewContent}
</div>, </div>,
+146 -87
View File
@@ -141,23 +141,63 @@ const normalizeContent = (content: string): string => {
}; };
type Token = type Token =
| { type: 'text'; value: string } | { key: string; type: 'text'; value: string }
| { type: 'url'; href: string } | { key: string; type: 'url'; href: string }
| { type: 'quoteLink'; number: number } | { key: string; type: 'quoteLink'; number: number }
| { type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference } | { key: string; type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
| { type: 'crossBoardLink'; display: string; route: string } | { key: string; type: 'crossBoardLink'; display: string; route: string }
| { type: 'spoiler'; tokens: Token[] }; | { 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 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 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 QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/; const URL_REGEX = /https?:\/\/[^\s<>[\]]+/;
const COMBINED_REGEX = new RegExp( const COMBINED_REGEX = new RegExp(
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`, `(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
'g', '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 { function getCrossboardRoute(fullPattern: string): string | null {
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, ''); const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
if (!isValidCrossboardPattern(`>>>/${pathPart}`)) { if (!isValidCrossboardPattern(`>>>/${pathPart}`)) {
@@ -177,7 +217,7 @@ function getCrossboardRoute(fullPattern: string): string | null {
return `/${pathPart}`; return `/${pathPart}`;
} }
function tokenize(text: string): Token[] { function tokenize(text: string, keyPrefix = ''): Token[] {
const tokens: Token[] = []; const tokens: Token[] = [];
let lastIndex = 0; let lastIndex = 0;
@@ -187,19 +227,26 @@ function tokenize(text: string): Token[] {
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {
const fullMatch = match[0]; const fullMatch = match[0];
const matchStart = match.index; const matchStart = match.index;
const matchEnd = regex.lastIndex;
if (matchStart > 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) { if (match[1] !== undefined) {
const innerContent = match[2]; 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) { } else if (match[3] !== undefined) {
const boardIdentifier = match[4]; const boardIdentifier = match[4];
const number = parseInt(match[5], 10); const number = parseInt(match[5], 10);
if (boardIdentifier && !Number.isNaN(number)) { if (boardIdentifier && !Number.isNaN(number)) {
tokens.push({ tokens.push({
key: makeTokenKey(keyPrefix, 'crossBoardNumberQuoteLink', matchStart, matchEnd),
type: 'crossBoardNumberQuoteLink', type: 'crossBoardNumberQuoteLink',
reference: { reference: {
boardIdentifier, boardIdentifier,
@@ -209,29 +256,43 @@ function tokenize(text: string): Token[] {
}, },
}); });
} else { } else {
tokens.push({ type: 'text', value: fullMatch }); tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
} }
} else if (match[6] !== undefined) { } else if (match[6] !== undefined) {
const pathPart = match[7]; const pathPart = match[7];
const fullPattern = `>>>/${pathPart}`; const fullPattern = `>>>/${pathPart}`;
const route = getCrossboardRoute(fullPattern); const route = getCrossboardRoute(fullPattern);
if (route) { 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 { } else {
tokens.push({ type: 'text', value: fullMatch }); tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
} }
} else if (match[8] !== undefined) { } else if (match[8] !== undefined) {
const number = parseInt(match[9], 10); 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) { } 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; lastIndex = regex.lastIndex;
} }
if (lastIndex < text.length) { 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; return tokens;
@@ -243,54 +304,6 @@ interface RenderContext {
communityAddress?: string; 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 <React.Fragment key={i}>{token.value}</React.Fragment>;
case 'url': {
const href = token.href;
const linkMediaInfo = getLinkMediaInfo(href);
const embedUrl = safeParseUrl(href);
if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) {
return (
<ContentLinkEmbed key={i} href={href} linkMediaInfo={linkMediaInfo}>
{href}
</ContentLinkEmbed>
);
}
return <React.Fragment key={i}>{renderAnchorLink(href, href, postCid, communityAddress)}</React.Fragment>;
}
case 'quoteLink':
return (
<span key={i} className={styles.inlineQuoteLink}>
<NumberQuoteLink number={token.number} threadPostCid={postCid} communityAddress={communityAddress} />
</span>
);
case 'crossBoardNumberQuoteLink':
return (
<span key={i} className={styles.inlineQuoteLink}>
<ExternalNumberQuoteLink reference={token.reference} />
</span>
);
case 'crossBoardLink':
return (
<Link key={i} to={token.route}>
{token.display}
</Link>
);
case 'spoiler':
return (
<span key={i} className='spoilertext'>
{renderTokens(token.tokens, context)}
</span>
);
}
});
}
interface MarkdownProps { interface MarkdownProps {
content: string; content: string;
title?: string; title?: string;
@@ -329,38 +342,30 @@ const NumberQuoteLink = ({ number, threadPostCid, communityAddress }: { number:
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />; return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />;
}; };
const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string, communityAddress?: string) => { const AnchorLink = ({ href, text }: { href: string; text: string }) => {
if (!href) { if (!href) {
return <span>{children}</span>; return <span>{text}</span>;
} }
if (is5chanLink(href)) { if (is5chanLink(href)) {
const internalPath = transform5chanLinkToInternal(href); const internalPath = transform5chanLinkToInternal(href);
if (internalPath) { if (internalPath) {
let shouldReplaceText = false; const internalRoute = normalizeInternalRouteHref(internalPath);
let displayText: React.ReactNode = text;
if (typeof children === 'string') { const isAutolinkedUrl = text.startsWith('http');
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');
if (isAutolinkedUrl) { if (isAutolinkedUrl) {
displayText = children; displayText = text;
} else if (shouldReplaceText && internalPath.match(/^\/[^/]+$/)) { } else if (internalRoute.match(/^\/[^/]+$/)) {
displayText = internalPath.substring(1); displayText = internalRoute.substring(1);
} else if (shouldReplaceText) { } else {
displayText = internalPath; displayText = internalRoute;
} }
return <Link to={internalPath}>{displayText}</Link>; return <Link to={internalRoute}>{displayText}</Link>;
} else { } else {
console.warn('Failed to transform 5chan link to internal path:', href); console.warn('Failed to transform 5chan link to internal path:', href);
return <Link to={href}>{children}</Link>; return <Link to={href}>{text}</Link>;
} }
} }
@@ -372,16 +377,68 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) || href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) ||
href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/) href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
) { ) {
return <Link to={href}>{children}</Link>; return <Link to={normalizeInternalRouteHref(href)}>{text}</Link>;
} }
return ( return (
<a href={href} target='_blank' rel='noopener noreferrer'> <a href={href} target='_blank' rel='noopener noreferrer'>
{children} {text}
</a> </a>
); );
}; };
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 (
<ContentLinkEmbed href={href} linkMediaInfo={linkMediaInfo}>
{href}
</ContentLinkEmbed>
);
}
return <AnchorLink href={href} text={href} />;
}
case 'quoteLink':
return (
<span className={styles.inlineQuoteLink}>
<NumberQuoteLink number={token.number} threadPostCid={postCid} communityAddress={communityAddress} />
</span>
);
case 'crossBoardNumberQuoteLink':
return (
<span className={styles.inlineQuoteLink}>
<ExternalNumberQuoteLink reference={token.reference} />
</span>
);
case 'crossBoardLink':
return <Link to={token.route}>{token.display}</Link>;
case 'spoiler':
return (
<span className='spoilertext'>
<TokenList tokens={token.tokens} context={context} />
</span>
);
}
};
const TokenList = ({ tokens, context }: { tokens: Token[]; context: RenderContext }) => {
return (
<>
{tokens.map((token) => (
<TokenNode key={token.key} token={token} context={context} />
))}
</>
);
};
const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => { const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => {
const location = useLocation(); const location = useLocation();
const params = useParams(); const params = useParams();
@@ -392,6 +449,8 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
const lines = normalized.split('\n'); const lines = normalized.split('\n');
const elements: React.ReactNode[] = []; const elements: React.ReactNode[] = [];
const context = { isInCatalogView, postCid, communityAddress };
lines.forEach((line, lineIndex) => { lines.forEach((line, lineIndex) => {
if (lineIndex > 0) { if (lineIndex > 0) {
elements.push(<br key={`br-${lineIndex}`} />); elements.push(<br key={`br-${lineIndex}`} />);
@@ -402,7 +461,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
const isGreentext = /^>[^>]/.test(line) || line === '>'; const isGreentext = /^>[^>]/.test(line) || line === '>';
const tokens = tokenize(line); const tokens = tokenize(line);
const lineElements = renderTokens(tokens, { isInCatalogView, postCid, communityAddress }); const lineElements = <TokenList tokens={tokens} context={context} />;
if (isGreentext) { if (isGreentext) {
elements.push( elements.push(
@@ -46,7 +46,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
const { t } = useTranslation(); const { t } = useTranslation();
const directories = useDirectories(); const directories = useDirectories();
const boardIdentifier = getBoardPath(communityAddress, directories); const boardIdentifier = getBoardPath(communityAddress, directories);
const handleClick = async () => { const copyDirectLink = async () => {
await safeCopyShareLink(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined); await safeCopyShareLink(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
onClose(); onClose();
}; };
@@ -55,11 +55,11 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
className={styles.postMenuItem} className={styles.postMenuItem}
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={copyDirectLink}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
handleClick(); copyDirectLink();
} }
}} }}
> >
@@ -70,7 +70,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => { const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleClick = async () => { const copyContentId = async () => {
await safeCopyToClipboard(cid, 'content id'); await safeCopyToClipboard(cid, 'content id');
onClose(); onClose();
}; };
@@ -79,11 +79,11 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
className={styles.postMenuItem} className={styles.postMenuItem}
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={copyContentId}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); 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 CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleClick = async () => { const copyUserId = async () => {
await safeCopyToClipboard(address, 'user id'); await safeCopyToClipboard(address, 'user id');
onClose(); onClose();
}; };
@@ -103,11 +103,11 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
className={styles.postMenuItem} className={styles.postMenuItem}
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={copyUserId}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
handleClick(); copyUserId();
} }
}} }}
> >
+4 -4
View File
@@ -286,13 +286,13 @@ const PostFormFields = ({
<select aria-label={t('board')} onChange={(e) => setPublishPostOptions({ communityAddress: e.target.value })} value={communityAddress}> <select aria-label={t('board')} onChange={(e) => setPublishPostOptions({ communityAddress: e.target.value })} value={communityAddress}>
<option value=''>{t('choose_one')}</option> <option value=''>{t('choose_one')}</option>
{isInAllView && {isInAllView &&
directories directories.map((community) =>
.filter((community) => community.title && community.address) community.title && community.address ? (
.map((community) => (
<option key={community.address} value={community.address}> <option key={community.address} value={community.address}>
{community.title} {community.title}
</option> </option>
))} ) : null,
)}
{isInModView && {isInModView &&
accountCommunityAddresses.map((address: string) => ( accountCommunityAddresses.map((address: string) => (
<option key={address} value={address}> <option key={address} value={address}>
@@ -64,7 +64,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
const { t } = useTranslation(); const { t } = useTranslation();
const directories = useDirectories(); const directories = useDirectories();
const boardIdentifier = getBoardPath(communityAddress, directories); const boardIdentifier = getBoardPath(communityAddress, directories);
const handleClick = async () => { const copyDirectLink = async () => {
await copyShareLinkSafe(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined); await copyShareLinkSafe(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
onClose(); onClose();
}; };
@@ -72,11 +72,11 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={copyDirectLink}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
handleClick(); copyDirectLink();
} }
}} }}
> >
@@ -87,7 +87,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => { const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleClick = async () => { const copyContentId = async () => {
await copyContentIdSafe(cid); await copyContentIdSafe(cid);
onClose(); onClose();
}; };
@@ -95,11 +95,11 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={copyContentId}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); 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 CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleClick = async () => { const copyUserId = async () => {
await copyUserIdSafe(address); await copyUserIdSafe(address);
onClose(); onClose();
}; };
@@ -118,11 +118,11 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={copyUserId}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
handleClick(); copyUserId();
} }
}} }}
> >
@@ -163,7 +163,7 @@ const { addChallenge } = useChallengesStore.getState();
const ReportPostButton = ({ onClose }: { onClose: () => void }) => { const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleClick = () => { const reportPost = () => {
alert("Reporting isn't available yet."); alert("Reporting isn't available yet.");
onClose(); onClose();
}; };
@@ -171,11 +171,11 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={reportPost}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
handleClick(); reportPost();
} }
}} }}
> >
@@ -235,7 +235,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
const { publishCommentEdit } = usePublishCommentEdit(deleteOptions); const { publishCommentEdit } = usePublishCommentEdit(deleteOptions);
const handleClick = async () => { const deletePost = async () => {
const confirmed = window.confirm(t('delete_post_confirm')); const confirmed = window.confirm(t('delete_post_confirm'));
if (!confirmed) { if (!confirmed) {
return; return;
@@ -254,11 +254,11 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={deletePost}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
handleClick(); deletePost();
} }
}} }}
> >
@@ -272,7 +272,7 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
const { hide, hidden, unhide } = useHide({ cid: cid || '' }); const { hide, hidden, unhide } = useHide({ cid: cid || '' });
const isInPostView = isPostPageView(useLocation().pathname, useParams()); const isInPostView = isPostPageView(useLocation().pathname, useParams());
const handleClick = () => { const togglePostHidden = () => {
if (hidden) { if (hidden) {
unhide(); unhide();
} else { } else {
@@ -285,11 +285,11 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={togglePostHidden}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
handleClick(); togglePostHidden();
} }
}} }}
> >
@@ -32,6 +32,7 @@ const testState = vi.hoisted(() => ({
quoteInsertNumber: undefined as number | undefined, quoteInsertNumber: undefined as number | undefined,
quoteInsertRequestId: 0, quoteInsertRequestId: 0,
quoteInsertSelectedText: '', quoteInsertSelectedText: '',
dragHandler: undefined as ((state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) | undefined,
replyIndex: undefined as number | undefined, replyIndex: undefined as number | undefined,
resetPublishReplyOptionsMock: vi.fn(), resetPublishReplyOptionsMock: vi.fn(),
resolvedCommunityAddress: undefined as string | undefined, resolvedCommunityAddress: undefined as string | undefined,
@@ -208,7 +209,10 @@ vi.mock('@react-spring/web', async () => {
}); });
vi.mock('@use-gesture/react', () => ({ vi.mock('@use-gesture/react', () => ({
useDrag: () => () => ({}), useDrag: (handler: (state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) => {
testState.dragHandler = handler;
return () => ({});
},
})); }));
let container: HTMLDivElement; let container: HTMLDivElement;
@@ -292,6 +296,7 @@ describe('ReplyModal', () => {
testState.quoteInsertNumber = undefined; testState.quoteInsertNumber = undefined;
testState.quoteInsertRequestId = 0; testState.quoteInsertRequestId = 0;
testState.quoteInsertSelectedText = ''; testState.quoteInsertSelectedText = '';
testState.dragHandler = undefined;
testState.replyIndex = undefined; testState.replyIndex = undefined;
testState.resetPublishReplyOptionsMock.mockReset(); testState.resetPublishReplyOptionsMock.mockReset();
testState.resolvedCommunityAddress = undefined; testState.resolvedCommunityAddress = undefined;
@@ -326,6 +331,8 @@ describe('ReplyModal', () => {
afterEach(() => { afterEach(() => {
act(() => root.unmount()); act(() => root.unmount());
container.remove(); 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 () => { 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'); 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 () => { it('initializes the drag spring once so typing rerenders do not recenter the modal', async () => {
await renderReplyModal('/mu/thread/post-1'); await renderReplyModal('/mu/thread/post-1');
+60 -29
View File
@@ -54,6 +54,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const account = useAccount(); const account = useAccount();
const { displayName } = account?.author || {}; const { displayName } = account?.author || {};
const textRef = useRef<HTMLTextAreaElement | null>(null); const textRef = useRef<HTMLTextAreaElement | null>(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<HTMLInputElement>(null); const urlRef = useRef<HTMLInputElement>(null);
const lastSelectionStartRef = useRef(0); const lastSelectionStartRef = useRef(0);
const lastSelectionEndRef = 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( const bind = useDrag(
({ active, event, offset: [ox, oy] }) => { ({ active, event, offset: [ox, oy] }) => {
const nextLeft = Math.round(ox); const nextLeft = Math.round(ox);
@@ -133,11 +164,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
if (active) { if (active) {
event.preventDefault(); event.preventDefault();
document.body.style.userSelect = 'none'; disableBodyTextSelection();
document.body.style.webkitUserSelect = 'none';
} else { } else {
document.body.style.userSelect = ''; restoreBodyTextSelection();
document.body.style.webkitUserSelect = '';
} }
api.start({ left: nextLeft, top: nextTop, immediate: true }); api.start({ left: nextLeft, top: nextTop, immediate: true });
}, },
@@ -148,6 +177,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}, },
); );
useEffect(() => {
return () => {
restoreBodyTextSelection();
};
}, []);
useEffect(() => { useEffect(() => {
if (nodeRef.current && isMobile) { if (nodeRef.current && isMobile) {
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
@@ -158,6 +193,21 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const parentCidRef = useRef<HTMLSpanElement>(null); const parentCidRef = useRef<HTMLSpanElement>(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(() => { useEffect(() => {
if (parentCidRef.current) { if (parentCidRef.current) {
const cidWidth = parentCidRef.current.offsetWidth; const cidWidth = parentCidRef.current.offsetWidth;
@@ -165,29 +215,6 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
} }
}, [parentCid]); }, [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(() => { useEffect(() => {
if (textRef.current) { if (textRef.current) {
const len = textRef.current.value.length; const len = textRef.current.value.length;
@@ -209,11 +236,15 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
setPublishReplyOptions({ content }); setPublishReplyOptions({ content });
checkContentLengthRef.current(content, t); checkContentLengthRef.current(content, t);
setTimeout(() => { const spellcheckTimeout = window.setTimeout(() => {
if (textRef.current) { if (textRef.current) {
textRef.current.spellcheck = true; textRef.current.spellcheck = true;
} }
}, 100); }, 100);
return () => {
window.clearTimeout(spellcheckTimeout);
};
} }
}, [showReplyModal, openEmpty, defaultParentQuote, selectedText]); }, [showReplyModal, openEmpty, defaultParentQuote, selectedText]);
@@ -351,7 +382,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
cols={48} cols={48}
rows={4} rows={4}
wrap='soft' wrap='soft'
ref={textRef} ref={setTextRef.current}
aria-label={t('comment')} aria-label={t('comment')}
spellCheck={true} spellCheck={true}
onInput={handleContentInput} onInput={handleContentInput}
@@ -152,9 +152,11 @@ const AccountSettingsEditor = ({
accountData.account.subscriptions = []; accountData.account.subscriptions = [];
} }
const uniqueSubscriptions = [...accountData.account.subscriptions]; const uniqueSubscriptions = [...accountData.account.subscriptions];
const knownSubscriptions = new Set(uniqueSubscriptions);
for (const address of communityAddresses) { for (const address of communityAddresses) {
if (!uniqueSubscriptions.includes(address)) { if (!knownSubscriptions.has(address)) {
uniqueSubscriptions.push(address); uniqueSubscriptions.push(address);
knownSubscriptions.add(address);
} }
} }
accountData.account.subscriptions = uniqueSubscriptions; accountData.account.subscriptions = uniqueSubscriptions;
@@ -219,6 +219,14 @@ const PureP2PBrowserSettings = ({ pureP2PBrowserRef }: SettingsProps) => {
const isElectron = window.electronApi?.isElectron === true; const isElectron = window.electronApi?.isElectron === true;
const getTrimmedLines = (value: string | undefined): string[] | undefined => {
return value?.split('\n').reduce<string[]>((lines, line) => {
const trimmedLine = line.trim();
if (trimmedLine) lines.push(trimmedLine);
return lines;
}, []);
};
const AdvancedSettings = () => { const AdvancedSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const account = useAccount() as AccountShape | undefined; const account = useAccount() as AccountShape | undefined;
@@ -234,27 +242,15 @@ const AdvancedSettings = () => {
const pureP2PBrowserRef = useRef<HTMLInputElement>(null); const pureP2PBrowserRef = useRef<HTMLInputElement>(null);
const handleSave = async () => { const handleSave = async () => {
const ipfsGatewayUrls = ipfsGatewayUrlsRef.current?.value const ipfsGatewayUrls = getTrimmedLines(ipfsGatewayUrlsRef.current?.value);
.split('\n')
.map((url) => url.trim())
.filter((url) => url !== '');
const mediaIpfsGatewayUrl = mediaIpfsGatewayUrlRef.current?.value.trim(); const mediaIpfsGatewayUrl = mediaIpfsGatewayUrlRef.current?.value.trim();
const pubsubKuboRpcClientsOptions = pubsubProvidersRef.current?.value const pubsubKuboRpcClientsOptions = getTrimmedLines(pubsubProvidersRef.current?.value);
.split('\n')
.map((url) => url.trim())
.filter((url) => url !== '');
const ethRpcUrls = ethRpcRef.current?.value const ethRpcUrls = getTrimmedLines(ethRpcRef.current?.value);
.split('\n')
.map((url) => url.trim())
.filter((url) => url !== '');
const httpRoutersOptions = httpRoutersRef.current?.value const httpRoutersOptions = getTrimmedLines(httpRoutersRef.current?.value);
.split('\n')
.map((url) => url.trim())
.filter((url) => url !== '');
const pkcRpcClientsOptions = p2pRpcRef.current?.value.trim() ? [p2pRpcRef.current.value.trim()] : undefined; const pkcRpcClientsOptions = p2pRpcRef.current?.value.trim() ? [p2pRpcRef.current.value.trim()] : undefined;
const dataPath = p2pDataPathRef.current?.value.trim() || undefined; const dataPath = p2pDataPathRef.current?.value.trim() || undefined;
@@ -177,7 +177,7 @@ const CryptoWalletsForm = ({ account }: { account: Account | undefined }) => {
<button <button
onClick={() => { onClick={() => {
const newIndex = walletsArray.length; const newIndex = walletsArray.length;
setWalletsArray([...walletsArray, defaultWalletObject]); setWalletsArray((currentWallets) => [...currentWallets, defaultWalletObject]);
setSelectedWallet(newIndex); setSelectedWallet(newIndex);
}} }}
> >
@@ -254,11 +254,10 @@ const getBrowserTransferStats = async (client?: Libp2pClientShape): Promise<Tran
const helia = client?._helia; const helia = client?._helia;
const counterStats = getTransferStatsFromHeliaCounters(helia); const counterStats = getTransferStatsFromHeliaCounters(helia);
const metricSources = [helia?.metrics, helia?.libp2p?.metrics].filter(Boolean); const metricSources = [helia?.metrics, helia?.libp2p?.metrics].filter(Boolean);
let metricStats: TransferStats = {}; const metricSnapshots = await Promise.all(metricSources.map((source) => getMetricSnapshot(source)));
const metricStats = metricSnapshots
for (const source of metricSources) { .map((snapshot) => getTransferStatsFromMetricSnapshot(snapshot))
metricStats = mergeTransferStats(metricStats, getTransferStatsFromMetricSnapshot(await getMetricSnapshot(source))); .reduce<TransferStats>((stats, nextStats) => mergeTransferStats(stats, nextStats), {});
}
return mergeTransferStats(counterStats, metricStats); return mergeTransferStats(counterStats, metricStats);
} catch { } catch {
@@ -32,16 +32,16 @@ const hashToSection = (hash: string, sectionIds = allSectionIds): string | null
const SettingsModal = () => { const SettingsModal = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const account = useAccount(); const account = useAccount();
const location = useLocation(); const { hash: locationHash, pathname } = useLocation();
const navigate = useNavigate(); 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 sectionIds = useMemo(() => (shouldShowP2PSettingsSection(account) ? [...allSectionIds, P2P_STATS_SECTION_ID] : allSectionIds), [account]);
const hashSection = hashToSection(hash, sectionIds); const hashSection = hashToSection(hash, sectionIds);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
const newPath = location.pathname.replace(/\/settings$/, ''); const newPath = pathname.replace(/\/settings$/, '');
navigate(newPath); navigate(newPath);
}, [location.pathname, navigate]); }, [pathname, navigate]);
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
@@ -77,7 +77,7 @@ const SettingsModal = () => {
const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]); const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]);
const basePath = location.pathname; const basePath = pathname;
const handleCategoryClick = (categoryId: string) => { const handleCategoryClick = (categoryId: string) => {
const isOpening = !visibleExpandedSections.has(categoryId); const isOpening = !visibleExpandedSections.has(categoryId);
@@ -9,7 +9,7 @@ const SubscriptionButton = ({ address }: { address: string }) => {
const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address }); const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address });
const [recentlyUnsubscribed, setRecentlyUnsubscribed] = useState(false); const [recentlyUnsubscribed, setRecentlyUnsubscribed] = useState(false);
const handleClick = () => { const toggleSubscription = () => {
if (recentlyUnsubscribed || !subscribed) { if (recentlyUnsubscribed || !subscribed) {
subscribe(); subscribe();
setRecentlyUnsubscribed(false); setRecentlyUnsubscribed(false);
@@ -29,10 +29,10 @@ const SubscriptionButton = ({ address }: { address: string }) => {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
handleClick(); toggleSubscription();
} }
}} }}
onClick={handleClick} onClick={toggleSubscription}
> >
{recentlyUnsubscribed || !subscribed ? t('subscribe') : t('unsubscribe')} {recentlyUnsubscribed || !subscribed ? t('subscribe') : t('unsubscribe')}
</span> </span>
+7 -3
View File
@@ -195,11 +195,13 @@ const buildReplies = (count: number, seed: number): SyntheticReply[] => {
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const quotedTargets: string[] = []; const quotedTargets: string[] = [];
const quotedTargetSet = new Set<string>();
const quoteTargetCount = replies.length === 0 ? 0 : Math.floor(random() * 3); const quoteTargetCount = replies.length === 0 ? 0 : Math.floor(random() * 3);
for (let quoteIndex = 0; quoteIndex < quoteTargetCount; quoteIndex += 1) { for (let quoteIndex = 0; quoteIndex < quoteTargetCount; quoteIndex += 1) {
const targetReply = replies[Math.floor(random() * replies.length)]; 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); 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) { for (let replyIndex = 0; replyIndex < previewReplyCount; replyIndex += 1) {
const quotedCids = random() < 0.45 ? [cid] : []; const quotedCids = random() < 0.45 ? [cid] : [];
const quotedCidSet = new Set(quotedCids);
const priorReply = replyIndex > 0 && random() < 0.3 ? previewReplies[Math.floor(random() * replyIndex)] : undefined; 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); quotedCids.push(priorReply.cid);
quotedCidSet.add(priorReply.cid);
} }
const replyText = `${quotedCids.map((quotedCid) => `>>${quotedCid.split('-').at(-1)}`).join(' ')} ${buildText(random, replyIndex)}`.trim(); const replyText = `${quotedCids.map((quotedCid) => `>>${quotedCid.split('-').at(-1)}`).join(' ')} ${buildText(random, replyIndex)}`.trim();
@@ -551,7 +555,7 @@ const Harness = () => {
if (currentHeightEstimates.length === 0) { if (currentHeightEstimates.length === 0) {
return undefined; 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)]; return sortedEstimates[Math.floor(sortedEstimates.length / 2)];
}, [catalogImageSize, catalogRowHeightEstimates, currentHeightEstimates, surface]); }, [catalogImageSize, catalogRowHeightEstimates, currentHeightEstimates, surface]);
+2 -2
View File
@@ -4,7 +4,7 @@ import { findDirectoryByAddress, type DirectoryCommunity, useDirectories } from
const isLikelyCommunityName = (value: string) => value.includes('.'); 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) { if (!communityAddress) {
return undefined; return undefined;
} }
@@ -36,7 +36,7 @@ export const getCommunityIdentifier = (communityAddress: string | undefined, dir
}; };
}; };
export const getCommunityIdentifiers = (communityAddresses: Array<string | undefined>, directories: DirectoryCommunity[]): CommunityIdentifier[] => const getCommunityIdentifiers = (communityAddresses: Array<string | undefined>, directories: DirectoryCommunity[]): CommunityIdentifier[] =>
communityAddresses.flatMap((communityAddress) => { communityAddresses.flatMap((communityAddress) => {
const community = getCommunityIdentifier(communityAddress, directories); const community = getCommunityIdentifier(communityAddress, directories);
return community ? [community] : []; return community ? [community] : [];
+1 -1
View File
@@ -8,7 +8,7 @@ interface DirectoriesMetadata {
updatedAt: number; updatedAt: number;
} }
export interface DirectoryFeatures { interface DirectoryFeatures {
postsPerPage?: number; postsPerPage?: number;
pseudonymityMode?: string; pseudonymityMode?: string;
nsfw?: boolean; nsfw?: boolean;
+1 -1
View File
@@ -3,7 +3,7 @@
* Board preview count is fixed at 3 entries to preserve compact block height. * 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 { export interface BlotterEntry {
id: string; id: string;
+3 -3
View File
@@ -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 DEFAULT_REPLY_VIRTUALIZATION_MODE: ReplyVirtualizationMode = 'item-size';
const REPLY_VIRTUALIZATION_MODES: ReplyVirtualizationMode[] = ['off', 'estimates', '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<typeof prepare>; type PreparedText = ReturnType<typeof prepare>;
type PreparedTextWithSegments = ReturnType<typeof prepareWithSegments>; type PreparedTextWithSegments = ReturnType<typeof prepareWithSegments>;
@@ -744,7 +744,7 @@ export const getFeedPostHeightEstimate = ({
return desktopResult; return desktopResult;
}; };
export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => { const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
if (!canUsePretext() || !post) { if (!canUsePretext() || !post) {
return DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP; return DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP;
} }
@@ -778,7 +778,7 @@ export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPC
return estimatedHeight; return estimatedHeight;
}; };
export const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => { const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => {
const cacheKey = const cacheKey =
row.length > 0 ? `${getCatalogEstimateCachePrefix(imageSize, metrics, showOPComment)}\u0000row\u0000${row.map((post) => post?.cid || '').join(',')}` : undefined; row.length > 0 ? `${getCatalogEstimateCachePrefix(imageSize, metrics, showOPComment)}\u0000row\u0000${row.map((post) => post?.cid || '').join(',')}` : undefined;
const cachedHeight = cacheKey ? catalogRowHeightEstimateCache.get(cacheKey) : undefined; const cachedHeight = cacheKey ? catalogRowHeightEstimateCache.get(cacheKey) : undefined;
+1 -1
View File
@@ -124,7 +124,7 @@ export const isDirectoryBoard = (identifier: string, communities: DirectoryCommu
export const isArchiveRoute = (pathname: string): boolean => { export const isArchiveRoute = (pathname: string): boolean => {
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, ''); const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
return /\/archive$/.test(normalizedPath); return normalizedPath.endsWith('/archive');
}; };
export const isFeedRoute = (pathname: string): boolean => { export const isFeedRoute = (pathname: string): boolean => {
+1 -1
View File
@@ -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'; return pathname === '/subs' || pathname === '/subs/settings' || pathname === '/subs/catalog' || pathname === '/subs/catalog/settings';
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { createElement } 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'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
-1
View File
@@ -1 +0,0 @@
export { default } from './archive';
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import useBoardsFilterStore from '../../../stores/use-boards-filter-store'; import useBoardsFilterStore from '../../../stores/use-boards-filter-store';
import { DISCLAIMER_ACCEPTED_KEY } from '../../../stores/use-disclaimer-modal-store'; import { DISCLAIMER_ACCEPTED_KEY } from '../../../stores/use-disclaimer-modal-store';
@@ -23,21 +23,18 @@ const BoardsFilterModal = () => {
const disclaimerAccepted = hasAcceptedDisclaimer(); const disclaimerAccepted = hasAcceptedDisclaimer();
const handleClickOutside = useCallback( useEffect(() => {
(event: MouseEvent) => { const closeFilterModalOnOutsideClick = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) { if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setShowFilterModal(false); 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 ( return (
<> <>
+38 -25
View File
@@ -511,7 +511,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
<span className={styles.cardNumber}>No. {number ?? 'N/A'}</span> <span className={styles.cardNumber}>No. {number ?? 'N/A'}</span>
{showBoard && boardPath && ( {showBoard && boardPath && (
<> <>
<span className={styles.cardBoardSeparator}> </span> <span className={styles.cardBoardSeparator}> - </span>
<span className={styles.cardBoard}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath}/</Link> : <span>/{boardDisplayPath}/</span>}</span> <span className={styles.cardBoard}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath}/</Link> : <span>/{boardDisplayPath}/</span>}</span>
</> </>
)} )}
@@ -588,6 +588,27 @@ const findBoardAddressByCode = (code: string, dirs: DirectoryCommunity[]): strin
return entry?.address || null; return entry?.address || null;
}; };
const ModQueueBoardCount = ({ normal, urgent }: { normal: number; urgent: number }) => {
const total = normal + urgent;
if (total === 0) return null;
return (
<strong>
(
{urgent > 0 && normal > 0 ? (
<>
<span className={styles.modQueueButtonCount}>{normal}</span>
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>+{urgent}</span>
</>
) : urgent > 0 ? (
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgent}</span>
) : (
<span className={styles.modQueueButtonCount}>{total}</span>
)}
)
</strong>
);
};
const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: ModQueueBoardSummaryProps) => { const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: ModQueueBoardSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter); const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
@@ -666,32 +687,16 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }:
return null; return null;
} }
const renderCount = (normal: number, urgent: number) => {
const total = normal + urgent;
if (total === 0) return null;
return (
<strong>
(
{urgent > 0 && normal > 0 ? (
<>
<span className={styles.modQueueButtonCount}>{normal}</span>
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>+{urgent}</span>
</>
) : urgent > 0 ? (
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgent}</span>
) : (
<span className={styles.modQueueButtonCount}>{total}</span>
)}
)
</strong>
);
};
return ( return (
<span className={styles.boardSummary}> <span className={styles.boardSummary}>
<button type='button' className={`${styles.boardSummaryLink} ${!selectedBoardFilter ? styles.boardSummaryLinkSelected : ''}`} onClick={handleSelectAll}> <button type='button' className={`${styles.boardSummaryLink} ${!selectedBoardFilter ? styles.boardSummaryLinkSelected : ''}`} onClick={handleSelectAll}>
{t('all')} {t('all')}
{totalNormal + totalUrgent > 0 && <> {renderCount(totalNormal, totalUrgent)}</>} {totalNormal + totalUrgent > 0 && (
<>
{' '}
<ModQueueBoardCount normal={totalNormal} urgent={totalUrgent} />
</>
)}
</button> </button>
{orderedAddresses.map((address) => { {orderedAddresses.map((address) => {
const boardPath = getBoardPath(address, directories); const boardPath = getBoardPath(address, directories);
@@ -709,7 +714,12 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }:
onClick={() => handleSelectBoard(address)} onClick={() => handleSelectBoard(address)}
> >
{displayText} {displayText}
{normal + urgent > 0 && <> {renderCount(normal, urgent)}</>} {normal + urgent > 0 && (
<>
{' '}
<ModQueueBoardCount normal={normal} urgent={urgent} />
</>
)}
</button> </button>
</React.Fragment> </React.Fragment>
); );
@@ -905,7 +915,10 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
}, [queuedCommentSnapshots, rememberCommentsInQueue]); }, [queuedCommentSnapshots, rememberCommentsInQueue]);
const feedWithHistory = useMemo(() => { const feedWithHistory = useMemo(() => {
const liveCids = new Set(feed.map((comment) => comment.cid).filter(Boolean)); const liveCids = feed.reduce<Set<string>>((cids, comment) => {
if (comment.cid) cids.add(comment.cid);
return cids;
}, new Set());
return [ return [
...feed, ...feed,
...(queuedCommentHistory.filter((comment) => { ...(queuedCommentHistory.filter((comment) => {
+9 -10
View File
@@ -54,7 +54,7 @@ const getEffectiveRouteUserState = (state: unknown): QueuedCommentRouteState | u
return getRouteUserState(window.history.state); return getRouteUserState(window.history.state);
}; };
export interface ReplyPaginationOverride { interface ReplyPaginationOverride {
hasMore?: boolean; hasMore?: boolean;
loadMore?: () => void; loadMore?: () => void;
replies: Comment[]; replies: Comment[];
@@ -273,7 +273,7 @@ export const Post = memo(
const PostPage = () => { const PostPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const params = useParams(); const params = useParams();
const location = useLocation(); const { key: locationKey, pathname, state: locationState } = useLocation();
const { commentCid } = params; const { commentCid } = params;
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled); const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId); const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId);
@@ -281,8 +281,8 @@ const PostPage = () => {
const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate); const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate);
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState); const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
const resolvedCommunityAddress = useResolvedCommunityAddress(); const resolvedCommunityAddress = useResolvedCommunityAddress();
const isInAllView = isAllView(location.pathname); const isInAllView = isAllView(pathname);
const routeState = useMemo(() => getEffectiveRouteUserState(location.state), [location.key, location.pathname, location.state]); const routeState = useMemo(() => getEffectiveRouteUserState(locationState), [locationKey, pathname, locationState]);
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled }); const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled });
const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]); 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: // These two effects split normal opens from explicit OP-top intents:
// the first keeps ordinary thread visits on `window.scrollTo(0, 0)`, while the // 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 // `consumedThreadTopScrollRef` so `scrollThreadContainerToTop(commentCid)` only
// replays for deliberate OP-link clicks and never for route-driven thread opens. // replays for deliberate OP-link clicks and never for route-driven thread opens.
useEffect(() => { useEffect(() => {
@@ -327,13 +327,13 @@ const PostPage = () => {
if (!commentCid || post?.cid !== commentCid) return; if (!commentCid || post?.cid !== commentCid) return;
if (requestedThreadTopCid !== commentCid) return; if (requestedThreadTopCid !== commentCid) return;
const consumedKey = `${location.key}:${commentCid}`; const consumedKey = `${locationKey}:${commentCid}`;
if (consumedThreadTopScrollRef.current === consumedKey) return; if (consumedThreadTopScrollRef.current === consumedKey) return;
if (scrollThreadContainerToTop(commentCid)) { if (scrollThreadContainerToTop(commentCid)) {
consumedThreadTopScrollRef.current = consumedKey; consumedThreadTopScrollRef.current = consumedKey;
} }
}, [commentCid, location.key, post?.cid, requestedThreadTopCid]); }, [commentCid, locationKey, post?.cid, requestedThreadTopCid]);
useEffect(() => { useEffect(() => {
const boardIdentifier = params.boardIdentifier; const boardIdentifier = params.boardIdentifier;
@@ -412,8 +412,7 @@ const PostPage = () => {
let cancelled = false; let cancelled = false;
startUpdate(); startUpdate();
void (async () => { void Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh())).then((results) => {
const results = await Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh()));
if (cancelled) return; if (cancelled) return;
const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled'); const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled');
@@ -423,7 +422,7 @@ const PostPage = () => {
if (rejectedResult?.status === 'rejected') { if (rejectedResult?.status === 'rejected') {
console.error('Failed to refresh thread comments:', rejectedResult.reason); console.error('Failed to refresh thread comments:', rejectedResult.reason);
} }
})(); });
return () => { return () => {
cancelled = true; cancelled = true;