mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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,5 +1,6 @@
|
|||||||
[](https://github.com/bitsocialnet/5chan/actions/workflows/ci.yml)
|
[](https://github.com/bitsocialnet/5chan/actions/workflows/ci.yml)
|
||||||
[](https://github.com/bitsocialnet/5chan/blob/master/scripts/write-coverage-badge.mjs)
|
[](https://github.com/bitsocialnet/5chan/blob/master/scripts/write-coverage-badge.mjs)
|
||||||
|
[](https://github.com/bitsocialnet/5chan/actions/workflows/ci.yml)
|
||||||
[](https://github.com/bitsocialnet/5chan/releases/latest)
|
[](https://github.com/bitsocialnet/5chan/releases/latest)
|
||||||
[](https://github.com/bitsocialnet/5chan/blob/master/LICENSE)
|
[](https://github.com/bitsocialnet/5chan/blob/master/LICENSE)
|
||||||
[](http://commitizen.github.io/cz-cli/)
|
[](http://commitizen.github.io/cz-cli/)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>{'/^>/'}</code> — threads starting with a quote (<em>{'">"'}</em> character as an html entity).
|
<code>{'/^>/'}</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>
|
||||||
|
|||||||
+10
@@ -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 "[Subscribe]" button, which adds it to their top bar. You can gain
|
. <strong>Build a following:</strong> Users can subscribe to your board via the "[Subscribe]" button, which adds it to their top bar. You can gain
|
||||||
subscribers through direct links, word of mouth, or search—no directory assignment or dev approval needed.
|
subscribers through direct links, word of mouth, or search, no directory assignment or dev approval needed.
|
||||||
</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 servers—anyone can fork, modify, and redeploy to their own domain. 5chan is
|
Devs can change directories via commits in the open-source repo. No centralized servers; anyone can fork, modify, and redeploy to their own domain. 5chan is
|
||||||
adminless with no central authority.
|
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 "[Subscribe]" button—
|
. Users can access it anytime via the search bar, direct links, or by subscribing with the "[Subscribe]" button;{' '}
|
||||||
<strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like "Anime &
|
<strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like "Anime &
|
||||||
Manga") and are handpicked by devs until directory voting is available.
|
Manga") 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>,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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] : [];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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,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 +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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user