Improve React Doctor score and badge (#1127)

* fix(react doctor): improve quality score and badge

* fix(react doctor): address review feedback

* fix(review): address final bot feedback
This commit is contained in:
Tommaso Casaburi
2026-05-10 18:25:13 +07:00
committed by GitHub
parent e7a6c377c9
commit 556973a445
47 changed files with 771 additions and 442 deletions
+18 -20
View File
@@ -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();
}
+13 -17
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { 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;
+107 -104
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { 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>{'/^&gt;/'}</code> threads starting with a quote (<em>{'">"'}</em> character as an html entity).
<code>{'/^&gt;/'}</code>: threads starting with a quote (<em>{'">"'}</em> character as an html entity).
</li>
<li>
<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>
@@ -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 &quot;[Subscribe]&quot; button, which adds it to their top bar. You can gain
subscribers through direct links, word of mouth, or searchno directory assignment or dev approval needed.
subscribers through direct links, word of mouth, or search, no directory assignment or dev approval needed.
</p>
</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 serversanyone can fork, modify, and redeploy to their own domain. 5chan is
Devs can change directories via commits in the open-source repo. No centralized servers; anyone can fork, modify, and redeploy to their own domain. 5chan is
adminless with no central authority.
</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 &quot;[Subscribe]&quot; button
. Users can access it anytime via the search bar, direct links, or by subscribing with the &quot;[Subscribe]&quot; button;{' '}
<strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like &quot;Anime &
Manga&quot;) 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>,
+146 -87
View File
@@ -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();
}
}}
>
+4 -4
View File
@@ -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');
+60 -29
View File
@@ -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>
+7 -3
View File
@@ -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]);
+2 -2
View File
@@ -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] : [];
+1 -1
View File
@@ -8,7 +8,7 @@ interface DirectoriesMetadata {
updatedAt: number;
}
export interface DirectoryFeatures {
interface DirectoryFeatures {
postsPerPage?: number;
pseudonymityMode?: string;
nsfw?: boolean;
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -69,7 +69,7 @@ const COMMENT_TOO_LONG_NOTICE = 'Comment too long. Click here to view the full t
const DEFAULT_REPLY_VIRTUALIZATION_MODE: ReplyVirtualizationMode = 'item-size';
const 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;
+1 -1
View File
@@ -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 => {
+1 -1
View File
@@ -78,7 +78,7 @@ export const isSettingsView = (pathname: string, params: ParamsType): boolean =>
);
};
export const isSubscriptionsView = (pathname: string, params: ParamsType): boolean => {
export const isSubscriptionsView = (pathname: string, _params: ParamsType): boolean => {
return pathname === '/subs' || pathname === '/subs/settings' || pathname === '/subs/catalog' || pathname === '/subs/catalog/settings';
};
+1 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
export { default } from './archive';
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import 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 (
<>
+38 -25
View File
@@ -511,7 +511,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
<span className={styles.cardNumber}>No. {number ?? 'N/A'}</span>
{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
View File
@@ -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;