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:
+18
-20
@@ -67,22 +67,22 @@ preloadReplyModal();
|
||||
const BoardLayout = () => {
|
||||
const params = useParams();
|
||||
const { accountCommentIndex, boardIdentifier, pageNumber } = params;
|
||||
const location = useLocation();
|
||||
const { pathname, search } = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInAllView = isAllView(pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(pathname, useParams());
|
||||
const isInModView = isModView(pathname);
|
||||
const directories = useDirectories();
|
||||
const communityAddress = boardIdentifier ? getCommunityAddress(boardIdentifier, directories) : undefined;
|
||||
const pendingPost = useSafeAccountComment({ commentIndex: accountCommentIndex });
|
||||
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
|
||||
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
||||
const isOnPostRoute = isPostRoute(location.pathname);
|
||||
const isOnPendingPostRoute = isPendingPostRoute(location.pathname);
|
||||
const isOnModQueueRoute = isModQueueRoute(location.pathname);
|
||||
const isOnArchiveRoute = isArchiveRoute(location.pathname);
|
||||
const isOnPostRoute = isPostRoute(pathname);
|
||||
const isOnPendingPostRoute = isPendingPostRoute(pathname);
|
||||
const isOnModQueueRoute = isModQueueRoute(pathname);
|
||||
const isOnArchiveRoute = isArchiveRoute(pathname);
|
||||
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute;
|
||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||
const isInCatalogView = isCatalogView(pathname, params);
|
||||
// Christmas theme
|
||||
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
||||
useEffect(() => {
|
||||
@@ -97,28 +97,26 @@ const BoardLayout = () => {
|
||||
// Close create board modal when navigating to a different page
|
||||
useEffect(() => {
|
||||
closeCreateBoardModal();
|
||||
}, [location.pathname, closeCreateBoardModal]);
|
||||
}, [pathname, closeCreateBoardModal]);
|
||||
|
||||
// force rerender of post form when navigating between pages, except when opening settings modal in current view
|
||||
const key = location.pathname.endsWith('/settings')
|
||||
? `${communityAddress}-${location.pathname.replace(/\/settings$/, '')}`
|
||||
: `${communityAddress}-${location.pathname}`;
|
||||
const key = pathname.endsWith('/settings') ? `${communityAddress}-${pathname.replace(/\/settings$/, '')}` : `${communityAddress}-${pathname}`;
|
||||
|
||||
if (pageNumber === '1') {
|
||||
return <Navigate to='/not-found' replace />;
|
||||
}
|
||||
|
||||
// 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 />;
|
||||
}
|
||||
|
||||
if (isLegacyBoardModQueueRoute(location.pathname)) {
|
||||
if (isLegacyBoardModQueueRoute(pathname)) {
|
||||
return <Navigate to='/not-found' replace />;
|
||||
}
|
||||
|
||||
// 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 />;
|
||||
}
|
||||
|
||||
@@ -126,8 +124,8 @@ const BoardLayout = () => {
|
||||
if (boardIdentifier && !isDirectoryBoard(boardIdentifier, directories)) {
|
||||
const canonicalBoardIdentifier = getBoardPath(boardIdentifier, directories);
|
||||
if (canonicalBoardIdentifier !== boardIdentifier) {
|
||||
const canonicalPath = location.pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
|
||||
return <Navigate to={canonicalPath + (location.search || '')} replace />;
|
||||
const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
|
||||
return <Navigate to={canonicalPath + (search || '')} replace />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,8 +199,8 @@ const GlobalLayout = () => {
|
||||
})),
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
const isInSettingsView = location.pathname.endsWith('/settings');
|
||||
const { pathname } = useLocation();
|
||||
const isInSettingsView = pathname.endsWith('/settings');
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -221,11 +221,11 @@ export const AutoButton = () => {
|
||||
|
||||
export const BottomButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = () => {
|
||||
const scrollToBottom = () => {
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' });
|
||||
};
|
||||
return (
|
||||
<button className='button' onClick={handleClick}>
|
||||
<button className='button' onClick={scrollToBottom}>
|
||||
{t('bottom')}
|
||||
</button>
|
||||
);
|
||||
@@ -233,11 +233,11 @@ export const BottomButton = () => {
|
||||
|
||||
export const TopButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = () => {
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
};
|
||||
return (
|
||||
<button className='button' onClick={handleClick}>
|
||||
<button className='button' onClick={scrollToTop}>
|
||||
{t('top')}
|
||||
</button>
|
||||
);
|
||||
@@ -507,13 +507,13 @@ export const MobileBoardButtons = () => {
|
||||
{searchText ? (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('search_results_for')}: <strong>{searchText}</strong>
|
||||
- {t('search_results_for')}: <strong>{searchText}</strong>
|
||||
</span>
|
||||
) : (
|
||||
filteredCount > 0 && (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
- {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
@@ -714,14 +714,14 @@ export const DesktopBoardButtons = () => {
|
||||
{isInCatalogView && searchText ? (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('search_results_for')}: <strong>{searchText}</strong>
|
||||
- {t('search_results_for')}: <strong>{searchText}</strong>
|
||||
</span>
|
||||
) : (
|
||||
isInCatalogView &&
|
||||
filteredCount > 0 && (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
- {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
|
||||
selector({
|
||||
accounts: {
|
||||
active: {
|
||||
subscriptions: new Array(testState.subscriptionsCount).fill('sub'),
|
||||
subscriptions: Array.from({ length: testState.subscriptionsCount }, () => 'sub'),
|
||||
},
|
||||
},
|
||||
activeAccountId: 'active',
|
||||
|
||||
@@ -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
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((code) => code.length > 0)
|
||||
.map((code) => code.toLowerCase());
|
||||
.reduce<string[]>((items, code) => {
|
||||
if (code.length > 0) {
|
||||
items.push(code.toLowerCase());
|
||||
}
|
||||
return items;
|
||||
}, []);
|
||||
return new Set(codes);
|
||||
};
|
||||
|
||||
@@ -118,7 +122,7 @@ const BoardsBarEditModal = () => {
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
closeBoardsBarEditModal();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
@@ -32,21 +32,18 @@ const SearchBar = ({ setShowSearchBar }: { setShowSearchBar: (show: boolean) =>
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleClickOutside = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
useEffect(() => {
|
||||
const closeSearchOnOutsideClick = (event: MouseEvent) => {
|
||||
if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) {
|
||||
setShowSearchBar(false);
|
||||
}
|
||||
},
|
||||
[searchBarRef, setShowSearchBar],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [handleClickOutside]);
|
||||
|
||||
document.addEventListener('mousedown', closeSearchOnOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeSearchOnOutsideClick);
|
||||
};
|
||||
}, [setShowSearchBar]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
@@ -113,8 +110,7 @@ const BoardsBarDesktop = () => {
|
||||
return [...(activeAccount?.subscriptions || [])];
|
||||
},
|
||||
(prev, next) => {
|
||||
if (prev.length !== next.length) return false;
|
||||
return prev.every((val, idx) => val === next[idx]);
|
||||
return prev.length === next.length && prev.every((val, idx) => val === next[idx]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -146,7 +142,7 @@ const BoardsBarDesktop = () => {
|
||||
const address = findBoardAddressByCode(code, directories);
|
||||
const isPlaceholder = !address;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const openDirectoryForPlaceholder = (e: React.MouseEvent) => {
|
||||
// If no address exists, prevent navigation and open directory modal
|
||||
if (!address) {
|
||||
e.preventDefault();
|
||||
@@ -168,13 +164,13 @@ const BoardsBarDesktop = () => {
|
||||
if (!address) openDirectoryModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onClick={openDirectoryForPlaceholder}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
) : (
|
||||
<Link to={`/${code}${isInCatalogView ? '/catalog' : ''}`} onClick={handleClick}>
|
||||
<Link to={`/${code}${isInCatalogView ? '/catalog' : ''}`} onClick={openDirectoryForPlaceholder}>
|
||||
{code}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('CatalogFilters', () => {
|
||||
expect(addedRowInputs[3]?.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('reorders, edits, and saves non-empty filters via the document Enter shortcut', async () => {
|
||||
it('reorders, edits, and saves non-empty filters from the form', async () => {
|
||||
renderCatalogFilters();
|
||||
await openModal();
|
||||
|
||||
@@ -255,6 +255,11 @@ describe('CatalogFilters', () => {
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
|
||||
});
|
||||
expect(testState.saveAndApplyFiltersMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(testState.saveAndApplyFiltersMock).toHaveBeenCalledTimes(1);
|
||||
const savedFilters = testState.saveAndApplyFiltersMock.mock.calls[0]?.[0] as FilterItem[] | undefined;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
@@ -91,7 +91,12 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const nonEmptyFilters = localFilterItems.filter((item) => item.text.trim() !== '').map(({ id: _id, ...rest }) => rest);
|
||||
const nonEmptyFilters = localFilterItems.reduce<Omit<CatalogFilterItemStore, 'id'>[]>((filters, item) => {
|
||||
if (item.text.trim() === '') return filters;
|
||||
const { id: _id, ...rest } = item;
|
||||
filters.push(rest);
|
||||
return filters;
|
||||
}, []);
|
||||
|
||||
saveAndApplyFilters(nonEmptyFilters);
|
||||
|
||||
@@ -104,20 +109,14 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
onSave();
|
||||
}, [saveAndApplyFilters, localFilterItems, onSave, resetFeed]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
},
|
||||
[handleSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const updateLocalFilterItem = useCallback((index: number, item: any) => {
|
||||
setLocalFilterItems((prev) => prev.map((f, i) => (i === index ? item : f)));
|
||||
}, []);
|
||||
@@ -136,100 +135,104 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<table className={styles.filtersTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>order</th>
|
||||
<th>on</th>
|
||||
<th>pattern</th>
|
||||
<th>color</th>
|
||||
<th>hide</th>
|
||||
<th>top</th>
|
||||
<th>del</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{localFilterItems.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
<td>
|
||||
<span
|
||||
className={styles.orderButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
moveLocalFilterItemUp(index);
|
||||
}
|
||||
}}
|
||||
onClick={() => moveLocalFilterItemUp(index)}
|
||||
>
|
||||
↑
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={styles.onCheckbox}
|
||||
checked={item.enabled}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
value={item.text}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, text: e.target.value })}
|
||||
ref={(el) => (inputRefs.current[index] = el)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} />
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.hide} onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.top} onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={styles.deleteButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
removeLocalFilterItem(index);
|
||||
}
|
||||
}}
|
||||
onClick={() => removeLocalFilterItem(index)}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.filterHits}>
|
||||
{currentCommunityAddress && item.communityFilteredCids?.has(currentCommunityAddress) && `x${item.communityCounts?.get(currentCommunityAddress) ?? 0}`}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<table className={styles.filtersTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>order</th>
|
||||
<th>on</th>
|
||||
<th>pattern</th>
|
||||
<th>color</th>
|
||||
<th>hide</th>
|
||||
<th>top</th>
|
||||
<th>del</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{localFilterItems.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
<td>
|
||||
<span
|
||||
className={styles.orderButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
moveLocalFilterItemUp(index);
|
||||
}
|
||||
}}
|
||||
onClick={() => moveLocalFilterItemUp(index)}
|
||||
>
|
||||
↑
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={styles.onCheckbox}
|
||||
checked={item.enabled}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
value={item.text}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, text: e.target.value })}
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} />
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.hide} onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.top} onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={styles.deleteButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
removeLocalFilterItem(index);
|
||||
}
|
||||
}}
|
||||
onClick={() => removeLocalFilterItem(index)}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<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>
|
||||
</tfoot>
|
||||
</table>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const FiltersProtip = () => {
|
||||
<strong>Matching whole words:</strong>
|
||||
</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>
|
||||
</ul>
|
||||
<ul>
|
||||
@@ -17,7 +17,7 @@ const FiltersProtip = () => {
|
||||
<strong>AND operator:</strong>
|
||||
</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>
|
||||
</ul>
|
||||
<ul>
|
||||
@@ -25,7 +25,7 @@ const FiltersProtip = () => {
|
||||
<strong>OR operator:</strong>
|
||||
</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>
|
||||
</ul>
|
||||
<ul>
|
||||
@@ -33,7 +33,7 @@ const FiltersProtip = () => {
|
||||
<strong>Mixing both operators:</strong>
|
||||
</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>
|
||||
</ul>
|
||||
<ul>
|
||||
@@ -41,7 +41,7 @@ const FiltersProtip = () => {
|
||||
<strong>Exact match search:</strong>
|
||||
</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>
|
||||
</ul>
|
||||
<ul>
|
||||
@@ -49,10 +49,10 @@ const FiltersProtip = () => {
|
||||
<strong>Wildcards:</strong>
|
||||
</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>
|
||||
<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>
|
||||
</ul>
|
||||
<ul>
|
||||
@@ -70,31 +70,31 @@ const FiltersProtip = () => {
|
||||
<ul>
|
||||
<strong>It is also possible to filter by regular expression:</strong>
|
||||
<li>
|
||||
<code>/^(?=.*detachable)(?=.*hats).*$/i</code> — AND operator.
|
||||
<code>/^(?=.*detachable)(?=.*hats).*$/i</code>: AND operator.
|
||||
</li>
|
||||
<li>
|
||||
<code>/^(?!.*touhou).*$/i</code> — NOT operator.
|
||||
<code>/^(?!.*touhou).*$/i</code>: NOT operator.
|
||||
</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>
|
||||
<code>/^$/</code> — threads with no text.
|
||||
<code>/^$/</code>: threads with no text.
|
||||
</li>
|
||||
</ul>
|
||||
<h4>Controls</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>On</strong> — enables or disables the filter.
|
||||
<strong>On</strong>: enables or disables the filter.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Color</strong> — highlights matched threads with the specified color.
|
||||
<strong>Color</strong>: highlights matched threads with the specified color.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Hide</strong> — hides matched threads.
|
||||
<strong>Hide</strong>: hides matched threads.
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+10
@@ -51,6 +51,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.colorPreview {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #aaa;
|
||||
vertical-align: middle;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.middleTxt input[type="text"] {
|
||||
width: 45px;
|
||||
margin: 0 2px;
|
||||
|
||||
@@ -83,7 +83,7 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999,
|
||||
zIndex: 30,
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
@@ -144,13 +144,6 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
className={styles.colorPreview}
|
||||
style={{
|
||||
backgroundColor: customColor || '#fff',
|
||||
display: 'inline-block',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '1px solid #aaa',
|
||||
verticalAlign: 'middle',
|
||||
marginLeft: '5px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
|
||||
@@ -8,11 +8,11 @@ import debounce from 'lodash/debounce';
|
||||
|
||||
const CatalogSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { pathname, search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchState, setSearchState] = useState({ open: false, value: '' });
|
||||
const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore();
|
||||
const queryParam = new URLSearchParams(location.search).get('q') ?? '';
|
||||
const queryParam = new URLSearchParams(search).get('q') ?? '';
|
||||
const openSearch = !!queryParam || searchState.open;
|
||||
const inputValue = searchState.open || searchState.value ? searchState.value : queryParam;
|
||||
|
||||
@@ -27,17 +27,17 @@ const CatalogSearch = () => {
|
||||
|
||||
const updateURL = useCallback(
|
||||
(searchText: string) => {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const urlParams = new URLSearchParams(search);
|
||||
if (searchText.trim()) {
|
||||
urlParams.set('q', searchText);
|
||||
} else {
|
||||
urlParams.delete('q');
|
||||
}
|
||||
const newSearch = urlParams.toString();
|
||||
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '');
|
||||
const newPath = pathname + (newSearch ? `?${newSearch}` : '');
|
||||
navigate(newPath, { replace: true });
|
||||
},
|
||||
[location.pathname, location.search, navigate],
|
||||
[pathname, search, navigate],
|
||||
);
|
||||
|
||||
const debouncedSetSearchFilter = useMemo(
|
||||
|
||||
@@ -318,7 +318,7 @@ describe('ChallengeModal', () => {
|
||||
'https://mintpass.org',
|
||||
);
|
||||
|
||||
await clickButton('Done');
|
||||
await clickButton('Close challenge');
|
||||
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']);
|
||||
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -129,6 +129,8 @@ const IframeChallenge = ({
|
||||
const attemptedLoadRef = useRef(false);
|
||||
const mountedRef = useRef(false);
|
||||
const handledAutoCompleteRef = useRef(false);
|
||||
const onAutoCompleteRef = useRef(onAutoComplete);
|
||||
onAutoCompleteRef.current = onAutoComplete;
|
||||
const expectedSessionId = getIframeSessionId(challenge);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -239,12 +241,12 @@ const IframeChallenge = ({
|
||||
const sessionId = (data as { sessionId?: unknown }).sessionId;
|
||||
if (sessionId !== expectedSessionId) return;
|
||||
handledAutoCompleteRef.current = true;
|
||||
onAutoComplete(challengeAnswers.filter((answer): answer is string => typeof answer === 'string'));
|
||||
onAutoCompleteRef.current(challengeAnswers.filter((answer): answer is string => typeof answer === 'string'));
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [expectedSessionId, iframeOrigin, onAutoComplete]);
|
||||
}, [expectedSessionId, iframeOrigin]);
|
||||
|
||||
if (!iframeUrlState) {
|
||||
return (
|
||||
@@ -289,7 +291,7 @@ const IframeChallenge = ({
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
<div className={styles.iframeCloseButton}>
|
||||
<button onClick={onDone}>Done</button>
|
||||
<button onClick={onDone}>Close challenge</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -339,11 +341,9 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
|
||||
({ active, event, offset: [ox, oy] }) => {
|
||||
if (active) {
|
||||
event.preventDefault();
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.webkitUserSelect = 'none';
|
||||
Object.assign(document.body.style, { userSelect: 'none', webkitUserSelect: 'none' });
|
||||
} else {
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.webkitUserSelect = '';
|
||||
Object.assign(document.body.style, { userSelect: '', webkitUserSelect: '' });
|
||||
}
|
||||
api.start({ x: ox, y: oy, immediate: true });
|
||||
},
|
||||
@@ -449,21 +449,21 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
|
||||
const publicationDetails = (
|
||||
<>
|
||||
<div className={styles.name}>
|
||||
<input type='text' value={displayName || capitalize(t('anonymous'))} disabled />
|
||||
<input type='text' value={displayName || capitalize(t('anonymous'))} disabled readOnly />
|
||||
</div>
|
||||
{title && (
|
||||
<div className={styles.subject}>
|
||||
<input type='text' value={title} disabled />
|
||||
<input type='text' value={title} disabled readOnly />
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{link && (
|
||||
<div className={styles.link}>
|
||||
<input type='text' value={link} disabled />
|
||||
<input type='text' value={link} disabled readOnly />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -39,7 +39,7 @@ const useScopedCidToNumber = (cids: string[]) => {
|
||||
uniqueCids.add(cid);
|
||||
}
|
||||
}
|
||||
return [...uniqueCids].sort();
|
||||
return Array.from(uniqueCids).toSorted();
|
||||
}, [cids]);
|
||||
|
||||
const cidToNumber = usePostNumberStore(
|
||||
|
||||
@@ -41,7 +41,7 @@ const CreateBoardModal = () => {
|
||||
bitsocial-cli
|
||||
</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
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ const CreateBoardModal = () => {
|
||||
<div className={styles.section}>
|
||||
<h3>Decentralization</h3>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ const DirectoryModal = () => {
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' target='_blank' rel='noopener noreferrer'>
|
||||
bitsocial-cli
|
||||
</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 &
|
||||
Manga") and are handpicked by devs until directory voting is available.
|
||||
</p>
|
||||
|
||||
@@ -230,6 +230,44 @@ describe('Markdown', () => {
|
||||
expect(links.find((link) => link.getAttribute('href') === '/fit')?.textContent).toBe('>>>/fit/');
|
||||
});
|
||||
|
||||
it('preserves trailing punctuation outside cross-board links', async () => {
|
||||
await renderMarkdown({
|
||||
content: 'see >>>/fit/, next',
|
||||
});
|
||||
|
||||
const link = container.querySelector('a');
|
||||
expect(link?.getAttribute('href')).toBe('/fit');
|
||||
expect(link?.textContent).toBe('>>>/fit/');
|
||||
expect(container.textContent).toBe('see >>>/fit/, next');
|
||||
});
|
||||
|
||||
it('normalizes hash-routed 5chan links before passing them to React Router', async () => {
|
||||
testState.internalPathByHref = {
|
||||
'https://5chan.local/#/mu': '#/mu',
|
||||
};
|
||||
|
||||
await renderMarkdown({
|
||||
content: 'https://5chan.local/#/mu',
|
||||
});
|
||||
|
||||
const link = container.querySelector('a');
|
||||
expect(link?.getAttribute('href')).toBe('/mu');
|
||||
expect(link?.textContent).toBe('https://5chan.local/#/mu');
|
||||
});
|
||||
|
||||
it('preserves balanced URL parentheses and leaves unmatched trailing punctuation outside links', async () => {
|
||||
await renderMarkdown({
|
||||
content: 'https://en.wikipedia.org/wiki/Function_(mathematics) https://example.com/path),',
|
||||
});
|
||||
|
||||
const links = Array.from(container.querySelectorAll('a'));
|
||||
expect(links[0]?.textContent).toBe('https://en.wikipedia.org/wiki/Function_(mathematics)');
|
||||
expect(links[0]?.getAttribute('href')).toBe('https://en.wikipedia.org/wiki/Function_(mathematics)');
|
||||
expect(links[1]?.textContent).toBe('https://example.com/path');
|
||||
expect(links[1]?.getAttribute('href')).toBe('https://example.com/path');
|
||||
expect(container.textContent).toBe('https://en.wikipedia.org/wiki/Function_(mathematics) https://example.com/path),');
|
||||
});
|
||||
|
||||
it('renders number quote links with op and unavailable state derived from cached comments', async () => {
|
||||
testState.comments = {
|
||||
'comment-42': { cid: 'comment-42', number: 42 },
|
||||
|
||||
@@ -206,7 +206,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
setPreviewPosition(null);
|
||||
};
|
||||
|
||||
const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
const openExternalQuote = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isResolving) {
|
||||
@@ -273,7 +273,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
aria-busy={isResolving || undefined}
|
||||
className={isResolving ? styles.inlineQuoteLinkResolving : undefined}
|
||||
href={`#/${boardLabel}`}
|
||||
onClick={handleClick}
|
||||
onClick={openExternalQuote}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={anchorRef}
|
||||
@@ -289,7 +289,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
className={previewClassName}
|
||||
data-thread-scroll-preview='true'
|
||||
ref={previewRef}
|
||||
style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 1000 }}
|
||||
style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 30 }}
|
||||
>
|
||||
{previewContent}
|
||||
</div>,
|
||||
|
||||
@@ -141,23 +141,63 @@ const normalizeContent = (content: string): string => {
|
||||
};
|
||||
|
||||
type Token =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'url'; href: string }
|
||||
| { type: 'quoteLink'; number: number }
|
||||
| { type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
|
||||
| { type: 'crossBoardLink'; display: string; route: string }
|
||||
| { type: 'spoiler'; tokens: Token[] };
|
||||
| { key: string; type: 'text'; value: string }
|
||||
| { key: string; type: 'url'; href: string }
|
||||
| { key: string; type: 'quoteLink'; number: number }
|
||||
| { key: string; type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
|
||||
| { key: string; type: 'crossBoardLink'; display: string; route: string }
|
||||
| { key: string; type: 'spoiler'; tokens: Token[] };
|
||||
|
||||
const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/;
|
||||
const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/;
|
||||
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
|
||||
const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/;
|
||||
const URL_REGEX = /https?:\/\/[^\s<>[\]]+/;
|
||||
|
||||
const COMBINED_REGEX = new RegExp(
|
||||
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
const makeTokenKey = (prefix: string, type: Token['type'], start: number, end: number): string => `${prefix}${type}:${start}:${end}`;
|
||||
|
||||
function normalizeInternalRouteHref(href: string): string {
|
||||
if (href.startsWith('/#/')) {
|
||||
return href.slice(2);
|
||||
}
|
||||
if (href.startsWith('#/')) {
|
||||
return href.slice(1);
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
function splitUrlTrailingText(rawHref: string): { href: string; trailingText: string } {
|
||||
let href = rawHref;
|
||||
let trailingText = '';
|
||||
|
||||
while (href) {
|
||||
const trailingPunctuationMatch = href.match(/[.,;:!?"']+$/);
|
||||
if (trailingPunctuationMatch) {
|
||||
trailingText = `${trailingPunctuationMatch[0]}${trailingText}`;
|
||||
href = href.slice(0, -trailingPunctuationMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (href.endsWith(')')) {
|
||||
const openingParens = (href.match(/\(/g) || []).length;
|
||||
const closingParens = (href.match(/\)/g) || []).length;
|
||||
if (closingParens > openingParens) {
|
||||
trailingText = `)${trailingText}`;
|
||||
href = href.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return { href, trailingText };
|
||||
}
|
||||
|
||||
function getCrossboardRoute(fullPattern: string): string | null {
|
||||
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
|
||||
if (!isValidCrossboardPattern(`>>>/${pathPart}`)) {
|
||||
@@ -177,7 +217,7 @@ function getCrossboardRoute(fullPattern: string): string | null {
|
||||
return `/${pathPart}`;
|
||||
}
|
||||
|
||||
function tokenize(text: string): Token[] {
|
||||
function tokenize(text: string, keyPrefix = ''): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
@@ -187,19 +227,26 @@ function tokenize(text: string): Token[] {
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const matchStart = match.index;
|
||||
const matchEnd = regex.lastIndex;
|
||||
|
||||
if (matchStart > lastIndex) {
|
||||
tokens.push({ type: 'text', value: text.slice(lastIndex, matchStart) });
|
||||
tokens.push({
|
||||
key: makeTokenKey(keyPrefix, 'text', lastIndex, matchStart),
|
||||
type: 'text',
|
||||
value: text.slice(lastIndex, matchStart),
|
||||
});
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
const innerContent = match[2];
|
||||
tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
|
||||
const key = makeTokenKey(keyPrefix, 'spoiler', matchStart, matchEnd);
|
||||
tokens.push({ key, type: 'spoiler', tokens: tokenize(innerContent, `${key}/`) });
|
||||
} else if (match[3] !== undefined) {
|
||||
const boardIdentifier = match[4];
|
||||
const number = parseInt(match[5], 10);
|
||||
if (boardIdentifier && !Number.isNaN(number)) {
|
||||
tokens.push({
|
||||
key: makeTokenKey(keyPrefix, 'crossBoardNumberQuoteLink', matchStart, matchEnd),
|
||||
type: 'crossBoardNumberQuoteLink',
|
||||
reference: {
|
||||
boardIdentifier,
|
||||
@@ -209,29 +256,43 @@ function tokenize(text: string): Token[] {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
tokens.push({ type: 'text', value: fullMatch });
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
|
||||
}
|
||||
} else if (match[6] !== undefined) {
|
||||
const pathPart = match[7];
|
||||
const fullPattern = `>>>/${pathPart}`;
|
||||
const route = getCrossboardRoute(fullPattern);
|
||||
if (route) {
|
||||
tokens.push({ type: 'crossBoardLink', display: fullPattern, route });
|
||||
const trailingText = fullMatch.startsWith(fullPattern) ? fullMatch.slice(fullPattern.length) : '';
|
||||
const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'crossBoardLink', matchStart, linkEnd), type: 'crossBoardLink', display: fullPattern, route });
|
||||
if (trailingText) {
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText });
|
||||
}
|
||||
} else {
|
||||
tokens.push({ type: 'text', value: fullMatch });
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
|
||||
}
|
||||
} else if (match[8] !== undefined) {
|
||||
const number = parseInt(match[9], 10);
|
||||
tokens.push({ type: 'quoteLink', number });
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'quoteLink', matchStart, matchEnd), type: 'quoteLink', number });
|
||||
} else if (match[10] !== undefined) {
|
||||
tokens.push({ type: 'url', href: fullMatch });
|
||||
const { href, trailingText } = splitUrlTrailingText(fullMatch);
|
||||
const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'url', matchStart, linkEnd), type: 'url', href });
|
||||
if (trailingText) {
|
||||
tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText });
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
tokens.push({
|
||||
key: makeTokenKey(keyPrefix, 'text', lastIndex, text.length),
|
||||
type: 'text',
|
||||
value: text.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
@@ -243,54 +304,6 @@ interface RenderContext {
|
||||
communityAddress?: string;
|
||||
}
|
||||
|
||||
function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[] {
|
||||
const { isInCatalogView, postCid, communityAddress } = context;
|
||||
|
||||
return tokens.map((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
return <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 {
|
||||
content: 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} />;
|
||||
};
|
||||
|
||||
const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string, communityAddress?: string) => {
|
||||
const AnchorLink = ({ href, text }: { href: string; text: string }) => {
|
||||
if (!href) {
|
||||
return <span>{children}</span>;
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
|
||||
if (is5chanLink(href)) {
|
||||
const internalPath = transform5chanLinkToInternal(href);
|
||||
if (internalPath) {
|
||||
let shouldReplaceText = false;
|
||||
|
||||
if (typeof children === 'string') {
|
||||
shouldReplaceText = children === href || children.trim() === href.trim();
|
||||
} else if (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string') {
|
||||
shouldReplaceText = children[0] === href || children[0].trim() === href.trim();
|
||||
}
|
||||
|
||||
let displayText: React.ReactNode = children;
|
||||
const childrenText = typeof children === 'string' ? children : Array.isArray(children) ? children[0] : '';
|
||||
const isAutolinkedUrl = shouldReplaceText && typeof childrenText === 'string' && childrenText.startsWith('http');
|
||||
const internalRoute = normalizeInternalRouteHref(internalPath);
|
||||
let displayText: React.ReactNode = text;
|
||||
const isAutolinkedUrl = text.startsWith('http');
|
||||
|
||||
if (isAutolinkedUrl) {
|
||||
displayText = children;
|
||||
} else if (shouldReplaceText && internalPath.match(/^\/[^/]+$/)) {
|
||||
displayText = internalPath.substring(1);
|
||||
} else if (shouldReplaceText) {
|
||||
displayText = internalPath;
|
||||
displayText = text;
|
||||
} else if (internalRoute.match(/^\/[^/]+$/)) {
|
||||
displayText = internalRoute.substring(1);
|
||||
} else {
|
||||
displayText = internalRoute;
|
||||
}
|
||||
|
||||
return <Link to={internalPath}>{displayText}</Link>;
|
||||
return <Link to={internalRoute}>{displayText}</Link>;
|
||||
} else {
|
||||
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(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
|
||||
) {
|
||||
return <Link to={href}>{children}</Link>;
|
||||
return <Link to={normalizeInternalRouteHref(href)}>{text}</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||
{children}
|
||||
{text}
|
||||
</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 location = useLocation();
|
||||
const params = useParams();
|
||||
@@ -392,6 +449,8 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
|
||||
const lines = normalized.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
const context = { isInCatalogView, postCid, communityAddress };
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
if (lineIndex > 0) {
|
||||
elements.push(<br key={`br-${lineIndex}`} />);
|
||||
@@ -402,7 +461,7 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
|
||||
const isGreentext = /^>[^>]/.test(line) || line === '>';
|
||||
|
||||
const tokens = tokenize(line);
|
||||
const lineElements = renderTokens(tokens, { isInCatalogView, postCid, communityAddress });
|
||||
const lineElements = <TokenList tokens={tokens} context={context} />;
|
||||
|
||||
if (isGreentext) {
|
||||
elements.push(
|
||||
|
||||
@@ -46,7 +46,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
const { t } = useTranslation();
|
||||
const directories = useDirectories();
|
||||
const boardIdentifier = getBoardPath(communityAddress, directories);
|
||||
const handleClick = async () => {
|
||||
const copyDirectLink = async () => {
|
||||
await safeCopyShareLink(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
|
||||
onClose();
|
||||
};
|
||||
@@ -55,11 +55,11 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={copyDirectLink}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
copyDirectLink();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -70,7 +70,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
|
||||
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
const copyContentId = async () => {
|
||||
await safeCopyToClipboard(cid, 'content id');
|
||||
onClose();
|
||||
};
|
||||
@@ -79,11 +79,11 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={copyContentId}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
copyContentId();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -94,7 +94,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
|
||||
const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
const copyUserId = async () => {
|
||||
await safeCopyToClipboard(address, 'user id');
|
||||
onClose();
|
||||
};
|
||||
@@ -103,11 +103,11 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={copyUserId}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
copyUserId();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -286,13 +286,13 @@ const PostFormFields = ({
|
||||
<select aria-label={t('board')} onChange={(e) => setPublishPostOptions({ communityAddress: e.target.value })} value={communityAddress}>
|
||||
<option value=''>{t('choose_one')}</option>
|
||||
{isInAllView &&
|
||||
directories
|
||||
.filter((community) => community.title && community.address)
|
||||
.map((community) => (
|
||||
directories.map((community) =>
|
||||
community.title && community.address ? (
|
||||
<option key={community.address} value={community.address}>
|
||||
{community.title}
|
||||
</option>
|
||||
))}
|
||||
) : null,
|
||||
)}
|
||||
{isInModView &&
|
||||
accountCommunityAddresses.map((address: string) => (
|
||||
<option key={address} value={address}>
|
||||
|
||||
@@ -64,7 +64,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
const { t } = useTranslation();
|
||||
const directories = useDirectories();
|
||||
const boardIdentifier = getBoardPath(communityAddress, directories);
|
||||
const handleClick = async () => {
|
||||
const copyDirectLink = async () => {
|
||||
await copyShareLinkSafe(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
|
||||
onClose();
|
||||
};
|
||||
@@ -72,11 +72,11 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={copyDirectLink}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
copyDirectLink();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -87,7 +87,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
|
||||
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
const copyContentId = async () => {
|
||||
await copyContentIdSafe(cid);
|
||||
onClose();
|
||||
};
|
||||
@@ -95,11 +95,11 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={copyContentId}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
copyContentId();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -110,7 +110,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
|
||||
const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
const copyUserId = async () => {
|
||||
await copyUserIdSafe(address);
|
||||
onClose();
|
||||
};
|
||||
@@ -118,11 +118,11 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={copyUserId}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
copyUserId();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -163,7 +163,7 @@ const { addChallenge } = useChallengesStore.getState();
|
||||
|
||||
const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = () => {
|
||||
const reportPost = () => {
|
||||
alert("Reporting isn't available yet.");
|
||||
onClose();
|
||||
};
|
||||
@@ -171,11 +171,11 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={reportPost}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
reportPost();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -235,7 +235,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
|
||||
|
||||
const { publishCommentEdit } = usePublishCommentEdit(deleteOptions);
|
||||
|
||||
const handleClick = async () => {
|
||||
const deletePost = async () => {
|
||||
const confirmed = window.confirm(t('delete_post_confirm'));
|
||||
if (!confirmed) {
|
||||
return;
|
||||
@@ -254,11 +254,11 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={deletePost}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
deletePost();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -272,7 +272,7 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
|
||||
const { hide, hidden, unhide } = useHide({ cid: cid || '' });
|
||||
const isInPostView = isPostPageView(useLocation().pathname, useParams());
|
||||
|
||||
const handleClick = () => {
|
||||
const togglePostHidden = () => {
|
||||
if (hidden) {
|
||||
unhide();
|
||||
} else {
|
||||
@@ -285,11 +285,11 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onClick={togglePostHidden}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
togglePostHidden();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -32,6 +32,7 @@ const testState = vi.hoisted(() => ({
|
||||
quoteInsertNumber: undefined as number | undefined,
|
||||
quoteInsertRequestId: 0,
|
||||
quoteInsertSelectedText: '',
|
||||
dragHandler: undefined as ((state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) | undefined,
|
||||
replyIndex: undefined as number | undefined,
|
||||
resetPublishReplyOptionsMock: vi.fn(),
|
||||
resolvedCommunityAddress: undefined as string | undefined,
|
||||
@@ -208,7 +209,10 @@ vi.mock('@react-spring/web', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@use-gesture/react', () => ({
|
||||
useDrag: () => () => ({}),
|
||||
useDrag: (handler: (state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) => {
|
||||
testState.dragHandler = handler;
|
||||
return () => ({});
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
@@ -292,6 +296,7 @@ describe('ReplyModal', () => {
|
||||
testState.quoteInsertNumber = undefined;
|
||||
testState.quoteInsertRequestId = 0;
|
||||
testState.quoteInsertSelectedText = '';
|
||||
testState.dragHandler = undefined;
|
||||
testState.replyIndex = undefined;
|
||||
testState.resetPublishReplyOptionsMock.mockReset();
|
||||
testState.resolvedCommunityAddress = undefined;
|
||||
@@ -326,6 +331,8 @@ describe('ReplyModal', () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.webkitUserSelect = '';
|
||||
});
|
||||
|
||||
it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => {
|
||||
@@ -493,6 +500,41 @@ describe('ReplyModal', () => {
|
||||
expect(modal?.style.touchAction).toBe('none');
|
||||
});
|
||||
|
||||
it('closes with Escape from the document on desktop', async () => {
|
||||
await renderReplyModal('/mu/thread/post-1');
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
|
||||
});
|
||||
|
||||
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores body selection styles if unmounted during a drag', async () => {
|
||||
document.body.style.userSelect = 'text';
|
||||
document.body.style.webkitUserSelect = 'auto';
|
||||
|
||||
await renderReplyModal('/mu/thread/post-1');
|
||||
|
||||
await act(async () => {
|
||||
testState.dragHandler?.({
|
||||
active: true,
|
||||
event: { preventDefault: vi.fn() },
|
||||
offset: [140, 100],
|
||||
});
|
||||
});
|
||||
|
||||
expect(document.body.style.userSelect).toBe('none');
|
||||
expect(document.body.style.webkitUserSelect).toBe('none');
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(React.Fragment));
|
||||
});
|
||||
|
||||
expect(document.body.style.userSelect).toBe('text');
|
||||
expect(document.body.style.webkitUserSelect).toBe('auto');
|
||||
});
|
||||
|
||||
it('initializes the drag spring once so typing rerenders do not recenter the modal', async () => {
|
||||
await renderReplyModal('/mu/thread/post-1');
|
||||
|
||||
|
||||
@@ -54,6 +54,16 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const account = useAccount();
|
||||
const { displayName } = account?.author || {};
|
||||
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 lastSelectionStartRef = useRef(0);
|
||||
const lastSelectionEndRef = useRef(0);
|
||||
@@ -126,6 +136,27 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
[],
|
||||
);
|
||||
|
||||
const bodySelectionStyleBeforeDragRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null);
|
||||
|
||||
const disableBodyTextSelection = () => {
|
||||
if (!bodySelectionStyleBeforeDragRef.current) {
|
||||
bodySelectionStyleBeforeDragRef.current = {
|
||||
userSelect: document.body.style.userSelect,
|
||||
webkitUserSelect: document.body.style.webkitUserSelect,
|
||||
};
|
||||
}
|
||||
Object.assign(document.body.style, { userSelect: 'none', webkitUserSelect: 'none' });
|
||||
};
|
||||
|
||||
const restoreBodyTextSelection = () => {
|
||||
const previousStyle = bodySelectionStyleBeforeDragRef.current;
|
||||
Object.assign(document.body.style, {
|
||||
userSelect: previousStyle?.userSelect ?? '',
|
||||
webkitUserSelect: previousStyle?.webkitUserSelect ?? '',
|
||||
});
|
||||
bodySelectionStyleBeforeDragRef.current = null;
|
||||
};
|
||||
|
||||
const bind = useDrag(
|
||||
({ active, event, offset: [ox, oy] }) => {
|
||||
const nextLeft = Math.round(ox);
|
||||
@@ -133,11 +164,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
|
||||
if (active) {
|
||||
event.preventDefault();
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.webkitUserSelect = 'none';
|
||||
disableBodyTextSelection();
|
||||
} else {
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.webkitUserSelect = '';
|
||||
restoreBodyTextSelection();
|
||||
}
|
||||
api.start({ left: nextLeft, top: nextTop, immediate: true });
|
||||
},
|
||||
@@ -148,6 +177,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
restoreBodyTextSelection();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeRef.current && isMobile) {
|
||||
const viewportHeight = window.innerHeight;
|
||||
@@ -158,6 +193,21 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
|
||||
const parentCidRef = useRef<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(() => {
|
||||
if (parentCidRef.current) {
|
||||
const cidWidth = parentCidRef.current.offsetWidth;
|
||||
@@ -165,29 +215,6 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}
|
||||
}, [parentCid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showReplyModal) {
|
||||
setTimeout(() => {
|
||||
if (textRef.current) {
|
||||
textRef.current.focus();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
if (!isMobile) {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [showReplyModal, closeModal, isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current) {
|
||||
const len = textRef.current.value.length;
|
||||
@@ -209,11 +236,15 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
setPublishReplyOptions({ content });
|
||||
checkContentLengthRef.current(content, t);
|
||||
|
||||
setTimeout(() => {
|
||||
const spellcheckTimeout = window.setTimeout(() => {
|
||||
if (textRef.current) {
|
||||
textRef.current.spellcheck = true;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(spellcheckTimeout);
|
||||
};
|
||||
}
|
||||
}, [showReplyModal, openEmpty, defaultParentQuote, selectedText]);
|
||||
|
||||
@@ -351,7 +382,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
cols={48}
|
||||
rows={4}
|
||||
wrap='soft'
|
||||
ref={textRef}
|
||||
ref={setTextRef.current}
|
||||
aria-label={t('comment')}
|
||||
spellCheck={true}
|
||||
onInput={handleContentInput}
|
||||
|
||||
@@ -152,9 +152,11 @@ const AccountSettingsEditor = ({
|
||||
accountData.account.subscriptions = [];
|
||||
}
|
||||
const uniqueSubscriptions = [...accountData.account.subscriptions];
|
||||
const knownSubscriptions = new Set(uniqueSubscriptions);
|
||||
for (const address of communityAddresses) {
|
||||
if (!uniqueSubscriptions.includes(address)) {
|
||||
if (!knownSubscriptions.has(address)) {
|
||||
uniqueSubscriptions.push(address);
|
||||
knownSubscriptions.add(address);
|
||||
}
|
||||
}
|
||||
accountData.account.subscriptions = uniqueSubscriptions;
|
||||
|
||||
@@ -219,6 +219,14 @@ const PureP2PBrowserSettings = ({ pureP2PBrowserRef }: SettingsProps) => {
|
||||
|
||||
const isElectron = window.electronApi?.isElectron === true;
|
||||
|
||||
const getTrimmedLines = (value: string | undefined): string[] | undefined => {
|
||||
return value?.split('\n').reduce<string[]>((lines, line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine) lines.push(trimmedLine);
|
||||
return lines;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const AdvancedSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const account = useAccount() as AccountShape | undefined;
|
||||
@@ -234,27 +242,15 @@ const AdvancedSettings = () => {
|
||||
const pureP2PBrowserRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
const ipfsGatewayUrls = ipfsGatewayUrlsRef.current?.value
|
||||
.split('\n')
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url !== '');
|
||||
const ipfsGatewayUrls = getTrimmedLines(ipfsGatewayUrlsRef.current?.value);
|
||||
|
||||
const mediaIpfsGatewayUrl = mediaIpfsGatewayUrlRef.current?.value.trim();
|
||||
|
||||
const pubsubKuboRpcClientsOptions = pubsubProvidersRef.current?.value
|
||||
.split('\n')
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url !== '');
|
||||
const pubsubKuboRpcClientsOptions = getTrimmedLines(pubsubProvidersRef.current?.value);
|
||||
|
||||
const ethRpcUrls = ethRpcRef.current?.value
|
||||
.split('\n')
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url !== '');
|
||||
const ethRpcUrls = getTrimmedLines(ethRpcRef.current?.value);
|
||||
|
||||
const httpRoutersOptions = httpRoutersRef.current?.value
|
||||
.split('\n')
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url !== '');
|
||||
const httpRoutersOptions = getTrimmedLines(httpRoutersRef.current?.value);
|
||||
|
||||
const pkcRpcClientsOptions = p2pRpcRef.current?.value.trim() ? [p2pRpcRef.current.value.trim()] : undefined;
|
||||
const dataPath = p2pDataPathRef.current?.value.trim() || undefined;
|
||||
|
||||
@@ -177,7 +177,7 @@ const CryptoWalletsForm = ({ account }: { account: Account | undefined }) => {
|
||||
<button
|
||||
onClick={() => {
|
||||
const newIndex = walletsArray.length;
|
||||
setWalletsArray([...walletsArray, defaultWalletObject]);
|
||||
setWalletsArray((currentWallets) => [...currentWallets, defaultWalletObject]);
|
||||
setSelectedWallet(newIndex);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -254,11 +254,10 @@ const getBrowserTransferStats = async (client?: Libp2pClientShape): Promise<Tran
|
||||
const helia = client?._helia;
|
||||
const counterStats = getTransferStatsFromHeliaCounters(helia);
|
||||
const metricSources = [helia?.metrics, helia?.libp2p?.metrics].filter(Boolean);
|
||||
let metricStats: TransferStats = {};
|
||||
|
||||
for (const source of metricSources) {
|
||||
metricStats = mergeTransferStats(metricStats, getTransferStatsFromMetricSnapshot(await getMetricSnapshot(source)));
|
||||
}
|
||||
const metricSnapshots = await Promise.all(metricSources.map((source) => getMetricSnapshot(source)));
|
||||
const metricStats = metricSnapshots
|
||||
.map((snapshot) => getTransferStatsFromMetricSnapshot(snapshot))
|
||||
.reduce<TransferStats>((stats, nextStats) => mergeTransferStats(stats, nextStats), {});
|
||||
|
||||
return mergeTransferStats(counterStats, metricStats);
|
||||
} catch {
|
||||
|
||||
@@ -32,16 +32,16 @@ const hashToSection = (hash: string, sectionIds = allSectionIds): string | null
|
||||
const SettingsModal = () => {
|
||||
const { t } = useTranslation();
|
||||
const account = useAccount();
|
||||
const location = useLocation();
|
||||
const { hash: locationHash, pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const hash = location.hash.slice(1);
|
||||
const hash = locationHash.slice(1);
|
||||
const sectionIds = useMemo(() => (shouldShowP2PSettingsSection(account) ? [...allSectionIds, P2P_STATS_SECTION_ID] : allSectionIds), [account]);
|
||||
const hashSection = hashToSection(hash, sectionIds);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
const newPath = location.pathname.replace(/\/settings$/, '');
|
||||
const newPath = pathname.replace(/\/settings$/, '');
|
||||
navigate(newPath);
|
||||
}, [location.pathname, navigate]);
|
||||
}, [pathname, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
@@ -77,7 +77,7 @@ const SettingsModal = () => {
|
||||
|
||||
const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]);
|
||||
|
||||
const basePath = location.pathname;
|
||||
const basePath = pathname;
|
||||
|
||||
const handleCategoryClick = (categoryId: string) => {
|
||||
const isOpening = !visibleExpandedSections.has(categoryId);
|
||||
|
||||
@@ -9,7 +9,7 @@ const SubscriptionButton = ({ address }: { address: string }) => {
|
||||
const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address });
|
||||
const [recentlyUnsubscribed, setRecentlyUnsubscribed] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
const toggleSubscription = () => {
|
||||
if (recentlyUnsubscribed || !subscribed) {
|
||||
subscribe();
|
||||
setRecentlyUnsubscribed(false);
|
||||
@@ -29,10 +29,10 @@ const SubscriptionButton = ({ address }: { address: string }) => {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
toggleSubscription();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onClick={toggleSubscription}
|
||||
>
|
||||
{recentlyUnsubscribed || !subscribed ? t('subscribe') : t('unsubscribe')}
|
||||
</span>
|
||||
|
||||
@@ -195,11 +195,13 @@ const buildReplies = (count: number, seed: number): SyntheticReply[] => {
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const quotedTargets: string[] = [];
|
||||
const quotedTargetSet = new Set<string>();
|
||||
const quoteTargetCount = replies.length === 0 ? 0 : Math.floor(random() * 3);
|
||||
for (let quoteIndex = 0; quoteIndex < quoteTargetCount; quoteIndex += 1) {
|
||||
const targetReply = replies[Math.floor(random() * replies.length)];
|
||||
if (targetReply?.cid && !quotedTargets.includes(targetReply.cid)) {
|
||||
if (targetReply?.cid && !quotedTargetSet.has(targetReply.cid)) {
|
||||
quotedTargets.push(targetReply.cid);
|
||||
quotedTargetSet.add(targetReply.cid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,9 +325,11 @@ const buildBoardItems = (count: number, seed: number): SyntheticBoardItem[] => {
|
||||
|
||||
for (let replyIndex = 0; replyIndex < previewReplyCount; replyIndex += 1) {
|
||||
const quotedCids = random() < 0.45 ? [cid] : [];
|
||||
const quotedCidSet = new Set(quotedCids);
|
||||
const priorReply = replyIndex > 0 && random() < 0.3 ? previewReplies[Math.floor(random() * replyIndex)] : undefined;
|
||||
if (priorReply?.cid && !quotedCids.includes(priorReply.cid)) {
|
||||
if (priorReply?.cid && !quotedCidSet.has(priorReply.cid)) {
|
||||
quotedCids.push(priorReply.cid);
|
||||
quotedCidSet.add(priorReply.cid);
|
||||
}
|
||||
|
||||
const replyText = `${quotedCids.map((quotedCid) => `>>${quotedCid.split('-').at(-1)}`).join(' ')} ${buildText(random, replyIndex)}`.trim();
|
||||
@@ -551,7 +555,7 @@ const Harness = () => {
|
||||
if (currentHeightEstimates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const sortedEstimates = [...currentHeightEstimates].sort((leftValue, rightValue) => leftValue - rightValue);
|
||||
const sortedEstimates = currentHeightEstimates.slice().sort((leftValue, rightValue) => leftValue - rightValue);
|
||||
return sortedEstimates[Math.floor(sortedEstimates.length / 2)];
|
||||
}, [catalogImageSize, catalogRowHeightEstimates, currentHeightEstimates, surface]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { findDirectoryByAddress, type DirectoryCommunity, useDirectories } from
|
||||
|
||||
const isLikelyCommunityName = (value: string) => value.includes('.');
|
||||
|
||||
export const getCommunityIdentifier = (communityAddress: string | undefined, directories: DirectoryCommunity[]): CommunityIdentifier | undefined => {
|
||||
const getCommunityIdentifier = (communityAddress: string | undefined, directories: DirectoryCommunity[]): CommunityIdentifier | undefined => {
|
||||
if (!communityAddress) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export const getCommunityIdentifier = (communityAddress: string | undefined, dir
|
||||
};
|
||||
};
|
||||
|
||||
export const getCommunityIdentifiers = (communityAddresses: Array<string | undefined>, directories: DirectoryCommunity[]): CommunityIdentifier[] =>
|
||||
const getCommunityIdentifiers = (communityAddresses: Array<string | undefined>, directories: DirectoryCommunity[]): CommunityIdentifier[] =>
|
||||
communityAddresses.flatMap((communityAddress) => {
|
||||
const community = getCommunityIdentifier(communityAddress, directories);
|
||||
return community ? [community] : [];
|
||||
|
||||
@@ -8,7 +8,7 @@ interface DirectoriesMetadata {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DirectoryFeatures {
|
||||
interface DirectoryFeatures {
|
||||
postsPerPage?: number;
|
||||
pseudonymityMode?: string;
|
||||
nsfw?: boolean;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Board preview count is fixed at 3 entries to preserve compact block height.
|
||||
*/
|
||||
|
||||
export type BlotterEntryKind = 'release' | 'manual';
|
||||
type BlotterEntryKind = 'release' | 'manual';
|
||||
|
||||
export interface BlotterEntry {
|
||||
id: string;
|
||||
|
||||
@@ -69,7 +69,7 @@ const COMMENT_TOO_LONG_NOTICE = 'Comment too long. Click here to view the full t
|
||||
const DEFAULT_REPLY_VIRTUALIZATION_MODE: ReplyVirtualizationMode = 'item-size';
|
||||
const REPLY_VIRTUALIZATION_MODES: ReplyVirtualizationMode[] = ['off', 'estimates', 'item-size'];
|
||||
|
||||
export const REPLY_HEIGHT_DATA_ATTRIBUTE = 'data-pretext-height';
|
||||
const REPLY_HEIGHT_DATA_ATTRIBUTE = 'data-pretext-height';
|
||||
|
||||
type PreparedText = ReturnType<typeof prepare>;
|
||||
type PreparedTextWithSegments = ReturnType<typeof prepareWithSegments>;
|
||||
@@ -744,7 +744,7 @@ export const getFeedPostHeightEstimate = ({
|
||||
return desktopResult;
|
||||
};
|
||||
|
||||
export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
|
||||
const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
|
||||
if (!canUsePretext() || !post) {
|
||||
return DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP;
|
||||
}
|
||||
@@ -778,7 +778,7 @@ export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPC
|
||||
return estimatedHeight;
|
||||
};
|
||||
|
||||
export const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => {
|
||||
const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => {
|
||||
const cacheKey =
|
||||
row.length > 0 ? `${getCatalogEstimateCachePrefix(imageSize, metrics, showOPComment)}\u0000row\u0000${row.map((post) => post?.cid || '').join(',')}` : undefined;
|
||||
const cachedHeight = cacheKey ? catalogRowHeightEstimateCache.get(cacheKey) : undefined;
|
||||
|
||||
@@ -124,7 +124,7 @@ export const isDirectoryBoard = (identifier: string, communities: DirectoryCommu
|
||||
|
||||
export const isArchiveRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
||||
return /\/archive$/.test(normalizedPath);
|
||||
return normalizedPath.endsWith('/archive');
|
||||
};
|
||||
|
||||
export const isFeedRoute = (pathname: string): boolean => {
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -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 useBoardsFilterStore from '../../../stores/use-boards-filter-store';
|
||||
import { DISCLAIMER_ACCEPTED_KEY } from '../../../stores/use-disclaimer-modal-store';
|
||||
@@ -23,21 +23,18 @@ const BoardsFilterModal = () => {
|
||||
|
||||
const disclaimerAccepted = hasAcceptedDisclaimer();
|
||||
|
||||
const handleClickOutside = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
useEffect(() => {
|
||||
const closeFilterModalOnOutsideClick = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||
setShowFilterModal(false);
|
||||
}
|
||||
},
|
||||
[modalRef, buttonRef, setShowFilterModal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [handleClickOutside]);
|
||||
|
||||
document.addEventListener('mousedown', closeFilterModalOnOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeFilterModalOnOutsideClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -511,7 +511,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
||||
<span className={styles.cardNumber}>No. {number ?? 'N/A'}</span>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
@@ -588,6 +588,27 @@ const findBoardAddressByCode = (code: string, dirs: DirectoryCommunity[]): strin
|
||||
return entry?.address || null;
|
||||
};
|
||||
|
||||
const ModQueueBoardCount = ({ normal, urgent }: { normal: number; urgent: number }) => {
|
||||
const total = normal + urgent;
|
||||
if (total === 0) return null;
|
||||
return (
|
||||
<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 { t } = useTranslation();
|
||||
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
|
||||
@@ -666,32 +687,16 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }:
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCount = (normal: number, urgent: number) => {
|
||||
const total = normal + urgent;
|
||||
if (total === 0) return null;
|
||||
return (
|
||||
<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 (
|
||||
<span className={styles.boardSummary}>
|
||||
<button type='button' className={`${styles.boardSummaryLink} ${!selectedBoardFilter ? styles.boardSummaryLinkSelected : ''}`} onClick={handleSelectAll}>
|
||||
{t('all')}
|
||||
{totalNormal + totalUrgent > 0 && <> {renderCount(totalNormal, totalUrgent)}</>}
|
||||
{totalNormal + totalUrgent > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<ModQueueBoardCount normal={totalNormal} urgent={totalUrgent} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{orderedAddresses.map((address) => {
|
||||
const boardPath = getBoardPath(address, directories);
|
||||
@@ -709,7 +714,12 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }:
|
||||
onClick={() => handleSelectBoard(address)}
|
||||
>
|
||||
{displayText}
|
||||
{normal + urgent > 0 && <> {renderCount(normal, urgent)}</>}
|
||||
{normal + urgent > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<ModQueueBoardCount normal={normal} urgent={urgent} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -905,7 +915,10 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
|
||||
}, [queuedCommentSnapshots, rememberCommentsInQueue]);
|
||||
|
||||
const feedWithHistory = useMemo(() => {
|
||||
const liveCids = new Set(feed.map((comment) => comment.cid).filter(Boolean));
|
||||
const liveCids = feed.reduce<Set<string>>((cids, comment) => {
|
||||
if (comment.cid) cids.add(comment.cid);
|
||||
return cids;
|
||||
}, new Set());
|
||||
return [
|
||||
...feed,
|
||||
...(queuedCommentHistory.filter((comment) => {
|
||||
|
||||
+9
-10
@@ -54,7 +54,7 @@ const getEffectiveRouteUserState = (state: unknown): QueuedCommentRouteState | u
|
||||
return getRouteUserState(window.history.state);
|
||||
};
|
||||
|
||||
export interface ReplyPaginationOverride {
|
||||
interface ReplyPaginationOverride {
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
replies: Comment[];
|
||||
@@ -273,7 +273,7 @@ export const Post = memo(
|
||||
const PostPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const { key: locationKey, pathname, state: locationState } = useLocation();
|
||||
const { commentCid } = params;
|
||||
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
|
||||
const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId);
|
||||
@@ -281,8 +281,8 @@ const PostPage = () => {
|
||||
const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate);
|
||||
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
|
||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const routeState = useMemo(() => getEffectiveRouteUserState(location.state), [location.key, location.pathname, location.state]);
|
||||
const isInAllView = isAllView(pathname);
|
||||
const routeState = useMemo(() => getEffectiveRouteUserState(locationState), [locationKey, pathname, locationState]);
|
||||
|
||||
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled });
|
||||
const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]);
|
||||
@@ -314,7 +314,7 @@ const PostPage = () => {
|
||||
|
||||
// These two effects split normal opens from explicit OP-top intents:
|
||||
// the first keeps ordinary thread visits on `window.scrollTo(0, 0)`, while the
|
||||
// second consumes `requestedThreadTopCid` once per `location.key` via
|
||||
// second consumes `requestedThreadTopCid` once per `locationKey` via
|
||||
// `consumedThreadTopScrollRef` so `scrollThreadContainerToTop(commentCid)` only
|
||||
// replays for deliberate OP-link clicks and never for route-driven thread opens.
|
||||
useEffect(() => {
|
||||
@@ -327,13 +327,13 @@ const PostPage = () => {
|
||||
if (!commentCid || post?.cid !== commentCid) return;
|
||||
if (requestedThreadTopCid !== commentCid) return;
|
||||
|
||||
const consumedKey = `${location.key}:${commentCid}`;
|
||||
const consumedKey = `${locationKey}:${commentCid}`;
|
||||
if (consumedThreadTopScrollRef.current === consumedKey) return;
|
||||
|
||||
if (scrollThreadContainerToTop(commentCid)) {
|
||||
consumedThreadTopScrollRef.current = consumedKey;
|
||||
}
|
||||
}, [commentCid, location.key, post?.cid, requestedThreadTopCid]);
|
||||
}, [commentCid, locationKey, post?.cid, requestedThreadTopCid]);
|
||||
|
||||
useEffect(() => {
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
@@ -412,8 +412,7 @@ const PostPage = () => {
|
||||
let cancelled = false;
|
||||
startUpdate();
|
||||
|
||||
void (async () => {
|
||||
const results = await Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh()));
|
||||
void Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh())).then((results) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled');
|
||||
@@ -423,7 +422,7 @@ const PostPage = () => {
|
||||
if (rejectedResult?.status === 'rejected') {
|
||||
console.error('Failed to refresh thread comments:', rejectedResult.reason);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
Reference in New Issue
Block a user