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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user