mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
refactor(react-doctor): raise score from 79 to 90
This commit is contained in:
+1
-10
@@ -120,16 +120,7 @@ const BoardLayout = () => {
|
||||
};
|
||||
|
||||
const GlobalLayout = () => {
|
||||
const [currentTheme] = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTheme) {
|
||||
document.body.classList.add(currentTheme);
|
||||
return () => {
|
||||
document.body.classList.remove(currentTheme);
|
||||
};
|
||||
}
|
||||
}, [currentTheme]);
|
||||
useTheme();
|
||||
|
||||
const { activeCid, parentNumber, threadNumber, threadCid, subplebbitAddress, closeModal, showReplyModal, scrollY } = useReplyModalStore();
|
||||
|
||||
|
||||
@@ -40,7 +40,18 @@ const BoardBlotter = () => {
|
||||
<tr>
|
||||
<td>
|
||||
[
|
||||
<span className={styles.hideButton} onClick={() => toggleVisibility()}>
|
||||
<span
|
||||
className={styles.hideButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleVisibility();
|
||||
}
|
||||
}}
|
||||
onClick={() => toggleVisibility()}
|
||||
>
|
||||
{isHidden ? t('show_blotter') : t('hide')}
|
||||
</span>
|
||||
]
|
||||
|
||||
@@ -471,7 +471,7 @@ export const PostPageStats = () => {
|
||||
<span>
|
||||
{pinned && `${capitalize(t('sticky'))} / `}
|
||||
{closed && `${capitalize(t('closed'))} / `}
|
||||
<Tooltip children={displayReplyCount} content={replyCountTooltip} /> / <Tooltip children={linkCount?.toString()} content={capitalize(t('links'))} />
|
||||
<Tooltip content={replyCountTooltip}>{displayReplyCount}</Tooltip> / <Tooltip content={capitalize(t('links'))}>{linkCount?.toString()}</Tooltip>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,7 +100,18 @@ const BoardHeader = () => {
|
||||
</div>
|
||||
<div className={styles.boardSubtitle}>
|
||||
{isInSubscriptionsView ? (
|
||||
<span className={styles.clickableSubtitle} onClick={() => navigate('/subs/settings#subscriptions-settings')}>
|
||||
<span
|
||||
className={styles.clickableSubtitle}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
navigate('/subs/settings#subscriptions-settings');
|
||||
}
|
||||
}}
|
||||
onClick={() => navigate('/subs/settings#subscriptions-settings')}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
) : !isInAllView && !isInModView && subtitle ? (
|
||||
|
||||
@@ -113,7 +113,18 @@ const BoardsBarEditModal = () => {
|
||||
const formKey = `${directoriesToString(visibleDirectories)}-${showSubscriptionsInBoardsBar}`;
|
||||
|
||||
return (
|
||||
<div className={styles.backdrop} onClick={handleBackdropClick}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeBoardsBarEditModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.boardsbarEditDialog}>
|
||||
<div className={styles.hd}>
|
||||
<h2>Custom Board List</h2>
|
||||
|
||||
@@ -166,7 +166,19 @@ const BoardsBarDesktop = () => {
|
||||
const linkContent = (
|
||||
<>
|
||||
{isPlaceholder ? (
|
||||
<span className={styles.placeholder} onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
<span
|
||||
className={styles.placeholder}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!address) openDirectoryModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
) : (
|
||||
@@ -219,7 +231,20 @@ const BoardsBarDesktop = () => {
|
||||
<>
|
||||
{' '}
|
||||
[
|
||||
<span className={styles.temporaryButton} onClick={() => setShowAllTemporarily(true)} style={{ cursor: 'pointer' }} title='Show all'>
|
||||
<span
|
||||
className={styles.temporaryButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowAllTemporarily(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowAllTemporarily(true)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title='Show all'
|
||||
>
|
||||
...
|
||||
</span>
|
||||
]{' '}
|
||||
@@ -229,18 +254,55 @@ const BoardsBarDesktop = () => {
|
||||
<>[{visibleSubscriptionAddresses.map((address: string, index: number) => renderSubscription(address, index, visibleSubscriptionAddresses.length))}] </>
|
||||
)}
|
||||
[
|
||||
<span className={styles.temporaryButton} onClick={() => openBoardsBarEditModal()} style={{ cursor: 'pointer' }}>
|
||||
<span
|
||||
className={styles.temporaryButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openBoardsBarEditModal();
|
||||
}
|
||||
}}
|
||||
onClick={() => openBoardsBarEditModal()}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{capitalize(t('edit'))}
|
||||
</span>
|
||||
] [
|
||||
<span className={styles.temporaryButton} onClick={() => openCreateBoardModal()} style={{ cursor: 'pointer' }}>
|
||||
<span
|
||||
className={styles.temporaryButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openCreateBoardModal();
|
||||
}
|
||||
}}
|
||||
onClick={() => openCreateBoardModal()}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{t('create_board')}
|
||||
</span>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.navTopRight}>
|
||||
[<Link to={!location.pathname.endsWith('settings') ? location.pathname.replace(/\/$/, '') + '/settings' : location.pathname}>{t('settings')}</Link>] [
|
||||
<span onClick={() => setShowSearchBar(!showSearchBar)}>{t('search')}</span>] [<Link to='/'>{t('home')}</Link>]
|
||||
<span
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowSearchBar(!showSearchBar);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowSearchBar(!showSearchBar)}
|
||||
>
|
||||
{t('search')}
|
||||
</span>
|
||||
] [<Link to='/'>{t('home')}</Link>]
|
||||
</span>
|
||||
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
|
||||
</div>
|
||||
@@ -336,7 +398,19 @@ const BoardsBarMobile = ({ subplebbitAddress }: { subplebbitAddress?: string })
|
||||
</div>
|
||||
<div className={styles.pageJump}>
|
||||
<Link to={location.pathname.replace(/\/$/, '') + '/settings'}>{t('settings')}</Link>
|
||||
<span onClick={() => setShowSearchBar(!showSearchBar)}>{capitalize(t('search'))}</span>
|
||||
<span
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowSearchBar(!showSearchBar);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowSearchBar(!showSearchBar)}
|
||||
>
|
||||
{capitalize(t('search'))}
|
||||
</span>
|
||||
<Link to='/'>{t('home')}</Link>
|
||||
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,9 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
const resetFeed = useFeedResetStore((state) => state.reset);
|
||||
|
||||
const [localFilterItems, setLocalFilterItems] = useState(() =>
|
||||
filterItems.map((item) => ({
|
||||
filterItems.map((item, i) => ({
|
||||
...item,
|
||||
id: `filter-${i}-${Date.now()}`,
|
||||
hide: item.hide ?? true,
|
||||
top: item.top ?? false,
|
||||
color: item.color ?? '',
|
||||
@@ -31,6 +32,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: `filter-${newIndex}-${Date.now()}`,
|
||||
text: '',
|
||||
enabled: true,
|
||||
count: 0,
|
||||
@@ -46,7 +48,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const nonEmptyFilters = localFilterItems.filter((item) => item.text.trim() !== '');
|
||||
const nonEmptyFilters = localFilterItems.filter((item) => item.text.trim() !== '').map(({ id: _id, ...rest }) => rest);
|
||||
|
||||
saveAndApplyFilters(nonEmptyFilters);
|
||||
|
||||
@@ -105,9 +107,20 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{localFilterItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<tr key={item.id ?? index}>
|
||||
<td>
|
||||
<span className={styles.orderButton} onClick={() => moveLocalFilterItemUp(index)}>
|
||||
<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>
|
||||
@@ -140,7 +153,18 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
<input type='checkbox' checked={item.top} onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.deleteButton} onClick={() => removeLocalFilterItem(index)}>
|
||||
<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>
|
||||
@@ -189,12 +213,49 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.overlay} onClick={showHelp ? closeHelp : closeModal} />
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
showHelp ? closeHelp() : closeModal();
|
||||
}
|
||||
}}
|
||||
onClick={showHelp ? closeHelp : closeModal}
|
||||
/>
|
||||
<div className={`${styles.modal} ${showHelp ? styles.filtersProtipModal : ''}`}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{showHelp ? t('filter_and_highlights_help') : t('filter_and_highlights')}</span>
|
||||
{!showHelp && <span className={styles.openHelpButton} title={t('help')} onClick={openHelp} />}
|
||||
<span className={styles.closeButton} title={t('close')} onClick={closeModal} />
|
||||
{!showHelp && (
|
||||
<span
|
||||
className={styles.openHelpButton}
|
||||
title={t('help')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openHelp();
|
||||
}
|
||||
}}
|
||||
onClick={openHelp}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={styles.closeButton}
|
||||
title={t('close')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
}}
|
||||
onClick={closeModal}
|
||||
/>
|
||||
</div>
|
||||
{showHelp ? <FiltersProtip /> : <FiltersTable key={currentSubplebbitAddress ?? 'none'} onSave={closeModal} />}
|
||||
</div>
|
||||
@@ -212,7 +273,18 @@ const CatalogFilters = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${styles.filtersButton} button`} onClick={() => setShowModal(true)}>
|
||||
<span
|
||||
className={`${styles.filtersButton} button`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowModal(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
{t('filters')}
|
||||
</span>
|
||||
{showModal && <FiltersModal closeModal={closeModal} />}
|
||||
|
||||
@@ -57,7 +57,20 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.colorClickbox} style={{ backgroundColor: item.color || '#fff' }} ref={refs.setReference} {...getReferenceProps()}>
|
||||
<span
|
||||
className={styles.colorClickbox}
|
||||
style={{ backgroundColor: item.color || '#fff' }}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsColorPickerOpen(!isColorPickerOpen);
|
||||
}
|
||||
}}
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{!item.color && '∕'}
|
||||
</span>
|
||||
|
||||
@@ -72,6 +85,14 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
bottom: 0,
|
||||
zIndex: 999,
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsColorPickerOpen(false);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -85,6 +106,7 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
style={floatingStyles}
|
||||
aria-labelledby={headingId}
|
||||
{...getFloatingProps()}
|
||||
role='presentation'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<table className={styles.colorGrid}>
|
||||
@@ -93,7 +115,19 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
<tr key={rowIndex}>
|
||||
{row.map((color, colorIndex) => (
|
||||
<td key={colorIndex}>
|
||||
<span className={styles.colorOption} style={{ backgroundColor: color }} onClick={() => setItemColor(color)} />
|
||||
<span
|
||||
className={styles.colorOption}
|
||||
style={{ backgroundColor: color }}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setItemColor(color);
|
||||
}
|
||||
}}
|
||||
onClick={() => setItemColor(color)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@@ -118,16 +152,46 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
marginLeft: '5px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
applyCustomColor();
|
||||
}
|
||||
}}
|
||||
onClick={applyCustomColor}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<span className={styles.button} onClick={() => setIsColorPickerOpen(false)}>
|
||||
<span
|
||||
className={styles.button}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsColorPickerOpen(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsColorPickerOpen(false)}
|
||||
>
|
||||
<span className={styles.buttonText}>Close</span>
|
||||
</span>
|
||||
<span className={styles.button} onClick={clearColor}>
|
||||
<span
|
||||
className={styles.button}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
clearColor();
|
||||
}
|
||||
}}
|
||||
onClick={clearColor}
|
||||
>
|
||||
<span className={styles.buttonText}>Clear</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import styles from './catalog-search.module.css';
|
||||
@@ -10,22 +10,18 @@ const CatalogSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [openSearch, setOpenSearch] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [searchState, setSearchState] = useState({ open: false, value: '' });
|
||||
const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore();
|
||||
const queryParam = new URLSearchParams(location.search).get('q') ?? '';
|
||||
const openSearch = !!queryParam || searchState.open;
|
||||
const inputValue = searchState.open || searchState.value ? searchState.value : queryParam;
|
||||
|
||||
// Extract query parameter from URL
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const queryParam = urlParams.get('q');
|
||||
if (queryParam) {
|
||||
setInputValue(queryParam);
|
||||
setSearchFilter(queryParam);
|
||||
setOpenSearch(true);
|
||||
}
|
||||
}, [location.search, setSearchFilter]);
|
||||
}, [queryParam, setSearchFilter]);
|
||||
|
||||
// Update URL when search changes
|
||||
const updateURL = useCallback(
|
||||
(searchText: string) => {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
@@ -41,66 +37,44 @@ const CatalogSearch = () => {
|
||||
[location.pathname, location.search, navigate],
|
||||
);
|
||||
|
||||
// Create a debounced version of setSearchFilter and URL update
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedSetSearchFilter = useCallback(
|
||||
debounce((text: string) => {
|
||||
if (text.trim()) {
|
||||
setSearchFilter(text);
|
||||
updateURL(text);
|
||||
} else {
|
||||
clearSearchFilter();
|
||||
updateURL('');
|
||||
}
|
||||
}, 300),
|
||||
const debouncedSetSearchFilter = useMemo(
|
||||
() =>
|
||||
debounce((text: string) => {
|
||||
if (text.trim()) {
|
||||
setSearchFilter(text);
|
||||
updateURL(text);
|
||||
} else {
|
||||
clearSearchFilter();
|
||||
updateURL('');
|
||||
}
|
||||
}, 300),
|
||||
[setSearchFilter, clearSearchFilter, updateURL],
|
||||
);
|
||||
|
||||
const handleToggleSearch = useCallback(() => {
|
||||
setOpenSearch((prev) => !prev);
|
||||
useEffect(() => {
|
||||
return () => debouncedSetSearchFilter.cancel();
|
||||
}, [debouncedSetSearchFilter]);
|
||||
|
||||
const handleToggleSearch = useCallback(() => {
|
||||
if (openSearch) {
|
||||
setInputValue('');
|
||||
clearSearchFilter();
|
||||
updateURL('');
|
||||
setSearchState({ open: false, value: '' });
|
||||
} else {
|
||||
setSearchState((prev) => ({ open: true, value: prev.value }));
|
||||
}
|
||||
}, [openSearch, clearSearchFilter, updateURL]);
|
||||
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setOpenSearch(false);
|
||||
setInputValue('');
|
||||
setSearchState({ open: false, value: '' });
|
||||
clearSearchFilter();
|
||||
updateURL('');
|
||||
}, [clearSearchFilter, updateURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openSearch) {
|
||||
const input = document.querySelector('input');
|
||||
input?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleCloseSearch();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [openSearch, handleCloseSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSetSearchFilter(inputValue);
|
||||
|
||||
return () => {
|
||||
debouncedSetSearchFilter.cancel();
|
||||
};
|
||||
}, [inputValue, debouncedSetSearchFilter]);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
const next = e.target.value;
|
||||
setSearchState((prev) => ({ ...prev, value: next }));
|
||||
debouncedSetSearchFilter(next);
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -108,14 +82,46 @@ const CatalogSearch = () => {
|
||||
return (
|
||||
<>
|
||||
{!isMobile && '['}
|
||||
<span className={`${styles.filtersButton} button`} onClick={handleToggleSearch}>
|
||||
<span
|
||||
className={`${styles.filtersButton} button`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleToggleSearch();
|
||||
}
|
||||
}}
|
||||
onClick={handleToggleSearch}
|
||||
>
|
||||
{t('search')}
|
||||
</span>
|
||||
{!isMobile && ']'}
|
||||
{openSearch && (
|
||||
<div className={styles.searchContainer}>
|
||||
<input type='text' value={inputValue} onChange={handleSearchChange} />
|
||||
<span className={styles.closeSearch} onClick={handleCloseSearch}>
|
||||
<input
|
||||
ref={(el) => el?.focus()}
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCloseSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={styles.closeSearch}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleCloseSearch();
|
||||
}
|
||||
}}
|
||||
onClick={handleCloseSearch}
|
||||
>
|
||||
✖
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -20,10 +20,122 @@ interface ChallengeProps {
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const TextChallenge = ({ challenge }: { challenge: string }) => <div className={styles.challengeMedia}>{challenge}</div>;
|
||||
|
||||
const ImageChallenge = ({ challenge }: { challenge: string }) => <img alt='' className={styles.challengeMedia} src={`data:image/png;base64,${challenge}`} />;
|
||||
|
||||
interface IframeChallengeProps {
|
||||
challenge: string;
|
||||
shortSubplebbitAddress?: string;
|
||||
subplebbitAddress?: string;
|
||||
readableUrl: string;
|
||||
closeModal: () => void;
|
||||
onDone: () => void;
|
||||
publicationDetails: React.ReactNode;
|
||||
}
|
||||
|
||||
const IframeChallenge = ({ challenge, shortSubplebbitAddress, subplebbitAddress, readableUrl, closeModal, onDone, publicationDetails }: IframeChallengeProps) => {
|
||||
const account = useAccount();
|
||||
const [theme] = useTheme();
|
||||
const [showIframeConfirmation, setShowIframeConfirmation] = useState(true);
|
||||
const [iframeUrlState, setIframeUrl] = useState('');
|
||||
const [iframeOrigin, setIframeOrigin] = useState('');
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const handleLoadIframe = useCallback(() => {
|
||||
const iframeUrl = challenge;
|
||||
if (!iframeUrl) return;
|
||||
|
||||
const rawUserAddress = account?.author?.address?.trim();
|
||||
const requiresUserAddress = iframeUrl.includes('{userAddress}');
|
||||
|
||||
if (requiresUserAddress && !rawUserAddress) {
|
||||
alert('Error: Unable to load challenge without your address. Please sign in and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedAddress = rawUserAddress ? encodeURIComponent(rawUserAddress) : undefined;
|
||||
const replacedUrl = requiresUserAddress && encodedAddress ? iframeUrl.replace(/\{userAddress\}/g, encodedAddress) : iframeUrl;
|
||||
|
||||
try {
|
||||
const validatedUrl = new URL(replacedUrl);
|
||||
if (validatedUrl.protocol !== 'https:') {
|
||||
throw new Error('Only HTTPS iframe challenges are supported');
|
||||
}
|
||||
validatedUrl.pathname = validatedUrl.pathname.replace(/\/{2,}/g, '/');
|
||||
validatedUrl.searchParams.set('theme', theme);
|
||||
const finalUrl = validatedUrl.toString();
|
||||
setIframeUrl(finalUrl);
|
||||
setIframeOrigin(validatedUrl.origin);
|
||||
setShowIframeConfirmation(false);
|
||||
} catch (error) {
|
||||
console.error('Invalid iframe challenge URL', { error });
|
||||
alert('Error: Invalid URL for authentication challenge');
|
||||
closeModal();
|
||||
}
|
||||
}, [account, challenge, closeModal, theme]);
|
||||
|
||||
const sendThemeToIframe = useCallback(() => {
|
||||
if (!iframeRef.current || !iframeOrigin) return;
|
||||
try {
|
||||
iframeRef.current.contentWindow?.postMessage({ type: 'plebbit-theme', theme, source: 'plebbit-5chan' }, iframeOrigin);
|
||||
} catch (error) {
|
||||
console.warn('Could not send theme to iframe:', error);
|
||||
}
|
||||
}, [iframeOrigin, theme]);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
sendThemeToIframe();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && iframeUrlState && iframeOrigin && !showIframeConfirmation) {
|
||||
sendThemeToIframe();
|
||||
}
|
||||
}, [iframeOrigin, iframeUrlState, sendThemeToIframe, showIframeConfirmation]);
|
||||
|
||||
if (showIframeConfirmation) {
|
||||
return (
|
||||
<>
|
||||
{publicationDetails}
|
||||
<div className={styles.challengeMediaWrapper}>
|
||||
<div className={`${styles.challengeMedia} ${styles.iframeChallengeWarning}`}>
|
||||
{shortSubplebbitAddress || subplebbitAddress || 'unknown board'} wants to open {readableUrl || 'an external site'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
<span className={styles.buttons}>
|
||||
<button onClick={handleLoadIframe}>Open</button>
|
||||
<button onClick={closeModal}>Cancel</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.challengeMediaWrapper} ${styles.iframeWrapper}`}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={iframeUrlState}
|
||||
sandbox='allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation'
|
||||
onLoad={handleIframeLoad}
|
||||
className={styles.iframe}
|
||||
title='Challenge authentication'
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
<div className={styles.iframeCloseButton}>
|
||||
<button onClick={onDone}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const challenges = challenge?.[0]?.challenges;
|
||||
const publication = challenge?.[1];
|
||||
@@ -40,10 +152,7 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
|
||||
const [currentChallengeIndex, setCurrentChallengeIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<string[]>([]);
|
||||
const [showIframeConfirmation, setShowIframeConfirmation] = useState(true);
|
||||
const [iframeUrlState, setIframeUrl] = useState('');
|
||||
const [iframeOrigin, setIframeOrigin] = useState('');
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
@@ -59,10 +168,8 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
const isIframeChallenge = currentChallenge?.type === 'url/iframe';
|
||||
|
||||
useEffect(() => {
|
||||
setShowIframeConfirmation(true);
|
||||
setIframeUrl('');
|
||||
setIframeOrigin('');
|
||||
}, [currentChallengeIndex, currentChallenge?.type]);
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const bind = useDrag(
|
||||
({ active, event, offset: [ox, oy] }) => {
|
||||
@@ -94,18 +201,14 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!publication) {
|
||||
return;
|
||||
}
|
||||
if (!publication) return;
|
||||
publication.publishChallengeAnswers(answers);
|
||||
setAnswers([]);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const onIframeClose = useCallback(() => {
|
||||
if (!publication) {
|
||||
return;
|
||||
}
|
||||
if (!publication) return;
|
||||
publication.publishChallengeAnswers(['']);
|
||||
closeModal();
|
||||
}, [closeModal, publication]);
|
||||
@@ -137,86 +240,30 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
const getChallengeUrl = useCallback(() => {
|
||||
try {
|
||||
const iframeUrl = currentChallenge?.challenge;
|
||||
if (!iframeUrl) {
|
||||
return '';
|
||||
}
|
||||
if (!iframeUrl) return '';
|
||||
const url = new URL(iframeUrl);
|
||||
if (url.hostname === 'mintpass.org') {
|
||||
return url.hostname;
|
||||
}
|
||||
if (url.hostname === 'mintpass.org') return url.hostname;
|
||||
return url.href;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [currentChallenge]);
|
||||
|
||||
const handleLoadIframe = useCallback(() => {
|
||||
const iframeUrl = currentChallenge?.challenge;
|
||||
if (!iframeUrl) {
|
||||
return;
|
||||
}
|
||||
const rawUserAddress = account?.author?.address?.trim();
|
||||
const requiresUserAddress = iframeUrl.includes('{userAddress}');
|
||||
|
||||
if (requiresUserAddress && !rawUserAddress) {
|
||||
alert('Error: Unable to load challenge without your address. Please sign in and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedAddress = rawUserAddress ? encodeURIComponent(rawUserAddress) : undefined;
|
||||
const replacedUrl = requiresUserAddress && encodedAddress ? iframeUrl.replace(/\{userAddress\}/g, encodedAddress) : iframeUrl;
|
||||
|
||||
const readableUrl = (() => {
|
||||
const url = getChallengeUrl();
|
||||
if (!url) return '';
|
||||
try {
|
||||
const validatedUrl = new URL(replacedUrl);
|
||||
if (validatedUrl.protocol !== 'https:') {
|
||||
throw new Error('Only HTTPS iframe challenges are supported');
|
||||
}
|
||||
validatedUrl.pathname = validatedUrl.pathname.replace(/\/{2,}/g, '/');
|
||||
validatedUrl.searchParams.set('theme', theme);
|
||||
const finalUrl = validatedUrl.toString();
|
||||
setIframeUrl(finalUrl);
|
||||
setIframeOrigin(validatedUrl.origin);
|
||||
setShowIframeConfirmation(false);
|
||||
} catch (error) {
|
||||
console.error('Invalid iframe challenge URL', { error });
|
||||
alert('Error: Invalid URL for authentication challenge');
|
||||
closeModal();
|
||||
return decodeURIComponent(url);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}, [account, closeModal, currentChallenge, theme]);
|
||||
|
||||
const sendThemeToIframe = useCallback(() => {
|
||||
if (!iframeRef.current || !iframeOrigin) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
iframeRef.current.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'plebbit-theme',
|
||||
theme,
|
||||
source: 'plebbit-5chan',
|
||||
},
|
||||
iframeOrigin,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Could not send theme to iframe:', error);
|
||||
}
|
||||
}, [iframeOrigin, theme]);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
sendThemeToIframe();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && iframeUrlState && iframeOrigin && !showIframeConfirmation) {
|
||||
sendThemeToIframe();
|
||||
}
|
||||
}, [iframeOrigin, iframeUrlState, sendThemeToIframe, showIframeConfirmation]);
|
||||
})();
|
||||
|
||||
if (!challenges?.length || !publication || !currentChallenge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isIframeVisible = isIframeChallenge && !showIframeConfirmation;
|
||||
const isIframeVisible = isIframeChallenge;
|
||||
|
||||
const containerClasses = [styles.container];
|
||||
if (isIframeVisible) {
|
||||
@@ -224,29 +271,10 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
}
|
||||
|
||||
const extraTitleParts: string[] = [];
|
||||
if (subplebbit) {
|
||||
extraTitleParts.push(`p/${subplebbit}`);
|
||||
}
|
||||
if (publicationType === 'vote' && votePreview) {
|
||||
extraTitleParts.push(votePreview.trim());
|
||||
}
|
||||
if (publication?.parentCid) {
|
||||
extraTitleParts.push(parentAddress ? `reply ${parentAddress}` : 'reply');
|
||||
}
|
||||
if (publicationContent && publicationType !== 'vote') {
|
||||
extraTitleParts.push(publicationContent);
|
||||
}
|
||||
const readableUrl = (() => {
|
||||
const url = getChallengeUrl();
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(url);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
if (subplebbit) extraTitleParts.push(`p/${subplebbit}`);
|
||||
if (publicationType === 'vote' && votePreview) extraTitleParts.push(votePreview.trim());
|
||||
if (publication?.parentCid) extraTitleParts.push(parentAddress ? `reply ${parentAddress}` : 'reply');
|
||||
if (publicationContent && publicationType !== 'vote') extraTitleParts.push(publicationContent);
|
||||
|
||||
const mobileX = isIframeVisible ? 5 : window.innerWidth / 2 - 150;
|
||||
const mobileY = isIframeVisible ? Math.max(10, (window.innerHeight - 600) / 2) : window.innerHeight / 2 - 200;
|
||||
@@ -266,71 +294,43 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
<button className={styles.closeIcon} onClick={closeModal} title='close' />
|
||||
</div>
|
||||
<div className={styles.publication}>
|
||||
{isIframeChallenge && !showIframeConfirmation ? null : (
|
||||
<>
|
||||
<div className={styles.name}>
|
||||
<input type='text' value={displayName || capitalize(t('anonymous'))} disabled />
|
||||
</div>
|
||||
{title && (
|
||||
<div className={styles.subject}>
|
||||
<input type='text' value={title} disabled />
|
||||
</div>
|
||||
)}
|
||||
{content && (
|
||||
<div className={styles.content}>
|
||||
<textarea value={content} disabled cols={48} rows={4} wrap='soft' />
|
||||
</div>
|
||||
)}
|
||||
{link && (
|
||||
<div className={styles.link}>
|
||||
<input type='text' value={link} disabled />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isIframeChallenge ? (
|
||||
<>
|
||||
{showIframeConfirmation ? (
|
||||
<IframeChallenge
|
||||
key={currentChallengeIndex}
|
||||
challenge={currentChallenge?.challenge ?? ''}
|
||||
shortSubplebbitAddress={shortSubplebbitAddress}
|
||||
subplebbitAddress={subplebbitAddress}
|
||||
readableUrl={readableUrl}
|
||||
closeModal={closeModal}
|
||||
onDone={onIframeClose}
|
||||
publicationDetails={
|
||||
<>
|
||||
<div className={styles.challengeMediaWrapper}>
|
||||
<div className={`${styles.challengeMedia} ${styles.iframeChallengeWarning}`}>
|
||||
{shortSubplebbitAddress || subplebbitAddress || 'unknown board'} wants to open {readableUrl || 'an external site'}
|
||||
<div className={styles.name}>
|
||||
<input type='text' value={displayName || capitalize(t('anonymous'))} disabled />
|
||||
</div>
|
||||
{title && (
|
||||
<div className={styles.subject}>
|
||||
<input type='text' value={title} disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
<span className={styles.buttons}>
|
||||
<button onClick={handleLoadIframe}>Open</button>
|
||||
<button onClick={closeModal}>Cancel</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={`${styles.challengeMediaWrapper} ${styles.iframeWrapper}`}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={iframeUrlState}
|
||||
sandbox='allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation'
|
||||
onLoad={handleIframeLoad}
|
||||
className={styles.iframe}
|
||||
title='Challenge authentication'
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
{/* <div className={styles.iframeInstruction}>
|
||||
Complete the challenge in the box above. Keep this window open until done.
|
||||
</div> */}
|
||||
<div className={styles.iframeCloseButton}>
|
||||
<button onClick={onIframeClose}>Done</button>
|
||||
)}
|
||||
{content && (
|
||||
<div className={styles.content}>
|
||||
<textarea value={content} disabled cols={48} rows={4} wrap='soft' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{link && (
|
||||
<div className={styles.link}>
|
||||
<input type='text' value={link} disabled />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.challengeContainer}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.challengeAnswer}
|
||||
type='text'
|
||||
autoComplete='off'
|
||||
@@ -340,11 +340,10 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
onKeyDown={onEnterKey}
|
||||
onChange={onAnswersChange}
|
||||
value={answers[currentChallengeIndex] || ''}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.challengeMediaWrapper}>
|
||||
{isTextChallenge && <div className={styles.challengeMedia}>{currentChallenge?.challenge}</div>}
|
||||
{isImageChallenge && <img alt='' className={styles.challengeMedia} src={`data:image/png;base64,${currentChallenge?.challenge}`} />}
|
||||
{isTextChallenge && <TextChallenge challenge={currentChallenge?.challenge ?? ''} />}
|
||||
{isImageChallenge && <ImageChallenge challenge={currentChallenge?.challenge ?? ''} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.challengeFooter}>
|
||||
@@ -375,8 +374,11 @@ const ChallengeModal = () => {
|
||||
const { challenges, removeChallenge } = useChallengesStore();
|
||||
const isOpen = !!challenges.length;
|
||||
const closeModal = () => removeChallenge();
|
||||
const current = challenges[0];
|
||||
const challenge = current?.challenge;
|
||||
const challengeId = current?.id ?? 0;
|
||||
|
||||
return isOpen && <Challenge challenge={challenges[0]} closeModal={closeModal} />;
|
||||
return isOpen && challenge ? <Challenge key={challengeId} challenge={challenge} closeModal={closeModal} /> : null;
|
||||
};
|
||||
|
||||
export default ChallengeModal;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Comment, useComment } from '@plebbit/plebbit-react-hooks';
|
||||
@@ -36,28 +36,20 @@ const useScopedCidToNumber = (cids: string[]) => {
|
||||
return [...uniqueCids].sort();
|
||||
}, [cids]);
|
||||
|
||||
// Subscribe only to CIDs this comment needs so unrelated thread updates do not rerender content.
|
||||
const cidNumbersSignature = usePostNumberStore(
|
||||
useCallback((state) => sortedUniqueCids.map((cid) => `${cid}:${state.cidToNumber[cid] ?? ''}`).join('|'), [sortedUniqueCids]),
|
||||
);
|
||||
const cidToNumber = usePostNumberStore((s) => s.cidToNumber);
|
||||
|
||||
return useMemo(() => {
|
||||
if (sortedUniqueCids.length === 0) {
|
||||
return {} as Record<string, number>;
|
||||
if (sortedUniqueCids.length === 0) {
|
||||
return {} as Record<string, number>;
|
||||
}
|
||||
|
||||
const nextCidToNumber: Record<string, number> = {};
|
||||
for (const cid of sortedUniqueCids) {
|
||||
const number = cidToNumber[cid];
|
||||
if (typeof number === 'number') {
|
||||
nextCidToNumber[cid] = number;
|
||||
}
|
||||
|
||||
const { cidToNumber } = usePostNumberStore.getState();
|
||||
const nextCidToNumber: Record<string, number> = {};
|
||||
|
||||
for (const cid of sortedUniqueCids) {
|
||||
const number = cidToNumber[cid];
|
||||
if (typeof number === 'number') {
|
||||
nextCidToNumber[cid] = number;
|
||||
}
|
||||
}
|
||||
|
||||
return nextCidToNumber;
|
||||
}, [sortedUniqueCids, cidNumbersSignature]);
|
||||
}
|
||||
return nextCidToNumber;
|
||||
};
|
||||
|
||||
const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
@@ -88,28 +80,23 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
const isReply = !!parentCid;
|
||||
const isReplyingToReply = isReply && parentCid !== postCid;
|
||||
|
||||
const contentNumbers = useMemo(() => {
|
||||
if (!content) return new Set<number>();
|
||||
const matches = content.matchAll(/(?<![>/\w])>>(\d+)(?![\d/])/g);
|
||||
return new Set([...matches].map((m) => parseInt(m[1], 10)));
|
||||
}, [content]);
|
||||
const contentNumbers = !content ? new Set<number>() : new Set([...content.matchAll(/(?<![>/\w])>>(\d+)(?![\d/])/g)].map((m) => parseInt(m[1], 10)));
|
||||
|
||||
const relevantQuotedCids = useMemo(() => {
|
||||
const relevantQuotedCids = (() => {
|
||||
const cids = quotedCids ? [...quotedCids] : [];
|
||||
if (parentCid) {
|
||||
cids.push(parentCid);
|
||||
}
|
||||
return cids;
|
||||
}, [quotedCids, parentCid]);
|
||||
})();
|
||||
|
||||
const cidToNumber = useScopedCidToNumber(relevantQuotedCids);
|
||||
const filteredQuotedCids = useMemo(() => {
|
||||
if (!quotedCids?.length) return [];
|
||||
return quotedCids.filter((cid: string) => {
|
||||
const num = cidToNumber[cid];
|
||||
return num === undefined || !contentNumbers.has(num);
|
||||
});
|
||||
}, [quotedCids, cidToNumber, contentNumbers]);
|
||||
const filteredQuotedCids = !quotedCids?.length
|
||||
? []
|
||||
: quotedCids.filter((cid: string) => {
|
||||
const num = cidToNumber[cid];
|
||||
return num === undefined || !contentNumbers.has(num);
|
||||
});
|
||||
|
||||
const shouldShowReplyingToReply = isReplyingToReply && (parentCid ? !contentNumbers.has(cidToNumber[parentCid] ?? -1) : true);
|
||||
|
||||
@@ -161,7 +148,26 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
<span className={styles.abbr}>
|
||||
<br />
|
||||
<br />
|
||||
<Trans i18nKey={'comment_too_long'} shouldUnescape={true} components={{ 1: <span key={cid} onClick={() => setShowFullComment(true)} /> }} />
|
||||
<Trans
|
||||
i18nKey={'comment_too_long'}
|
||||
shouldUnescape={true}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
key={cid}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowFullComment(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowFullComment(true)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{edit && original?.content !== content && (
|
||||
@@ -174,7 +180,11 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
values={{ timestamp: getFormattedDate(edit?.timestamp) }}
|
||||
shouldUnescape={true}
|
||||
components={{
|
||||
1: <Tooltip key={edit?.timestamp} content={getFormattedTimeAgo(edit?.timestamp)} children={<Fragment key={edit?.timestamp}></Fragment>} />,
|
||||
1: (
|
||||
<Tooltip key={edit?.timestamp} content={getFormattedTimeAgo(edit?.timestamp)}>
|
||||
<Fragment key={edit?.timestamp}></Fragment>
|
||||
</Tooltip>
|
||||
),
|
||||
}}
|
||||
/>{' '}
|
||||
{reason && <>{t('reason_reason', { reason: reason, interpolation: { escapeValue: false } })} </>}
|
||||
@@ -182,13 +192,45 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
<Trans
|
||||
i18nKey={'click_here_to_hide_original'}
|
||||
shouldUnescape={true}
|
||||
components={{ 1: <span key={cid} className={styles.showOriginal} onClick={() => setShowOriginal(!showOriginal)} /> }}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
key={cid}
|
||||
className={styles.showOriginal}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowOriginal(!showOriginal);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowOriginal(!showOriginal)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey={'click_here_to_show_original'}
|
||||
shouldUnescape={true}
|
||||
components={{ 1: <span key={cid} className={styles.showOriginal} onClick={() => setShowOriginal(!showOriginal)} /> }}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
key={cid}
|
||||
className={styles.showOriginal}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowOriginal(!showOriginal);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowOriginal(!showOriginal)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
@@ -200,13 +242,14 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
||||
<br />
|
||||
<br />
|
||||
<Tooltip
|
||||
children={`(${t('user_banned')})`}
|
||||
content={`${t('ban_expires_at', {
|
||||
address: subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress }),
|
||||
timestamp: getFormattedDate(post?.author?.subplebbit?.banExpiresAt),
|
||||
interpolation: { escapeValue: false },
|
||||
})}${reason ? `. ${capitalize(t('reason'))}: "${reason}"` : ''}`}
|
||||
/>
|
||||
>
|
||||
{`(${t('user_banned')})`}
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
{!cid && !hasFailedState && (
|
||||
|
||||
@@ -36,18 +36,71 @@ const Thumbnail = ({ commentMediaInfo, deleted, displayHeight, displayWidth, isF
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
|
||||
|
||||
if (type === 'gif') {
|
||||
thumbnailComponent = <img src={gifFrameUrl || url} alt='' onClick={() => setShowThumbnail(false)} />;
|
||||
thumbnailComponent = (
|
||||
<img
|
||||
src={gifFrameUrl || url}
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'video') {
|
||||
thumbnailComponent = thumbnail ? (
|
||||
<img src={thumbnail} alt='' />
|
||||
) : (
|
||||
// show first frame of the video, as a workaround for Safari not loading thumbnails
|
||||
<video src={`${url}#t=0.001`} onClick={() => setShowThumbnail(false)} />
|
||||
<video
|
||||
src={`${url}#t=0.001`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'webpage') {
|
||||
thumbnailComponent = <img src={thumbnail} alt='' onClick={() => setShowThumbnail(false)} />;
|
||||
thumbnailComponent = (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'iframe') {
|
||||
thumbnailComponent = iframeThumbnail ? <img src={iframeThumbnail} alt='' onClick={() => setShowThumbnail(false)} /> : null;
|
||||
thumbnailComponent = iframeThumbnail ? (
|
||||
<img
|
||||
src={iframeThumbnail}
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
) : null;
|
||||
} else if (type === 'audio') {
|
||||
thumbnailComponent = <audio src={url} controls />;
|
||||
}
|
||||
@@ -60,7 +113,20 @@ const Thumbnail = ({ commentMediaInfo, deleted, displayHeight, displayWidth, isF
|
||||
return deleted || removed ? (
|
||||
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='File deleted' />
|
||||
) : spoiler ? (
|
||||
<img className={styles.spoiler} src='assets/spoiler.png' alt='' onClick={() => setShowThumbnail(false)} />
|
||||
<img
|
||||
className={styles.spoiler}
|
||||
src='assets/spoiler.png'
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
) : isOutOfFeed ? (
|
||||
<span className={`${isFloatingEmbed ? styles.floatingEmbed : styles.subplebbitAvatar}`}>{thumbnailComponent}</span>
|
||||
) : isMobile || isReply ? (
|
||||
@@ -70,7 +136,19 @@ const Thumbnail = ({ commentMediaInfo, deleted, displayHeight, displayWidth, isF
|
||||
!hasThumbnail &&
|
||||
linkWithoutThumbnail &&
|
||||
(canEmbed(linkWithoutThumbnail) ? (
|
||||
<span onClick={() => setShowThumbnail(false)}>{getHostname(url)}</span>
|
||||
<span
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
>
|
||||
{getHostname(url)}
|
||||
</span>
|
||||
) : (
|
||||
<a href={url} target='_blank' rel='noreferrer'>
|
||||
{getHostname(url) || (url.length > 30 ? url.slice(0, 30) + '...' : url)}
|
||||
@@ -99,11 +177,43 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
{type === 'iframe' && url ? (
|
||||
<Embed url={url} />
|
||||
) : type === 'gif' ? (
|
||||
<img src={url} alt='' onClick={disableToggle ? undefined : () => setShowThumbnail(true)} />
|
||||
<img
|
||||
src={url}
|
||||
alt=''
|
||||
role={disableToggle ? undefined : 'button'}
|
||||
tabIndex={disableToggle ? undefined : 0}
|
||||
onKeyDown={
|
||||
disableToggle
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick={disableToggle ? undefined : () => setShowThumbnail(true)}
|
||||
/>
|
||||
) : type === 'video' ? (
|
||||
<video src={url} controls autoPlay loop muted />
|
||||
) : type === 'webpage' ? (
|
||||
<img src={thumbnail} alt='' onClick={disableToggle ? undefined : () => setShowThumbnail(true)} />
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt=''
|
||||
role={disableToggle ? undefined : 'button'}
|
||||
tabIndex={disableToggle ? undefined : 0}
|
||||
onKeyDown={
|
||||
disableToggle
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick={disableToggle ? undefined : () => setShowThumbnail(true)}
|
||||
/>
|
||||
) : null}
|
||||
{isMobile && type && (
|
||||
<div className={styles.fileInfo}>
|
||||
@@ -116,7 +226,18 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
)}
|
||||
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
||||
<div className={styles.closeButton}>
|
||||
<span className='button' onClick={() => setShowThumbnail(true)}>
|
||||
<span
|
||||
className='button'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(true)}
|
||||
>
|
||||
{t('close')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -160,7 +281,20 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
|
||||
className={`${isOutOfFeed ? styles.subplebbitAvatar : styles.thumbnailBig} ${styles.thumbnail} ${isImageExpanded && isMobile ? styles.removeFloat : ''}`}
|
||||
style={spoilerDimensions}
|
||||
>
|
||||
<img className={styles.spoiler} src='assets/spoiler.png' alt='' onClick={() => setIsImageExpanded(true)} />
|
||||
<img
|
||||
className={styles.spoiler}
|
||||
src='assets/spoiler.png'
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsImageExpanded(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsImageExpanded(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -174,7 +308,24 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
|
||||
{hasError ? (
|
||||
<img src='assets/filedeleted-res.gif' alt='File deleted' />
|
||||
) : (
|
||||
<img src={url} onError={handleError} alt='' onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)} />
|
||||
<img
|
||||
src={url}
|
||||
onError={handleError}
|
||||
alt=''
|
||||
role={disableToggle ? undefined : 'button'}
|
||||
tabIndex={disableToggle ? undefined : 0}
|
||||
onKeyDown={
|
||||
disableToggle
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsImageExpanded(!isImageExpanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{isImageExpanded && type && (
|
||||
@@ -196,7 +347,24 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
|
||||
{hasError ? (
|
||||
<img src='assets/filedeleted-res.gif' alt='File deleted' />
|
||||
) : (
|
||||
<img src={url} onError={handleError} alt='' onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)} />
|
||||
<img
|
||||
src={url}
|
||||
onError={handleError}
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={
|
||||
disableToggle
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsImageExpanded(!isImageExpanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,18 @@ const CreateBoardModal = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.backdrop} onClick={handleBackdropClick}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeCreateBoardModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.createBoardDialog}>
|
||||
<div className={styles.hd}>
|
||||
<h2>Create a Board</h2>
|
||||
|
||||
@@ -18,7 +18,18 @@ const DirectoryModal = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.backdrop} ${isHomeView ? styles.backdropHome : ''}`} onClick={handleBackdropClick}>
|
||||
<div
|
||||
className={`${styles.backdrop} ${isHomeView ? styles.backdropHome : ''}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeDirectoryModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.directoryDialog}>
|
||||
<div className={styles.hd}>
|
||||
<h2>Submit a Board to a Directory</h2>
|
||||
|
||||
@@ -31,7 +31,18 @@ const DisclaimerModal = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.backdrop} onClick={handleBackdropClick}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeDisclaimerModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.disclaimerDialog}>
|
||||
<div className={styles.hd}>
|
||||
<h2>Disclaimer</h2>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { autoUpdate, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
|
||||
import {
|
||||
@@ -47,7 +47,9 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
});
|
||||
const signer = isAccountCommentAuthor ? account?.signer : null;
|
||||
const latestPostRef = useRef(post);
|
||||
latestPostRef.current = post;
|
||||
useEffect(() => {
|
||||
latestPostRef.current = post;
|
||||
}, [post]);
|
||||
const onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), []);
|
||||
|
||||
const defaultPublishEditOptions = useMemo(() => {
|
||||
|
||||
@@ -1,46 +1,50 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useReducer, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copyToClipboard } from '../../lib/utils/clipboard-utils';
|
||||
import styles from './error-display.module.css';
|
||||
|
||||
type State = { showAfterDelay: boolean; feedbackMessageKey: string | null };
|
||||
|
||||
function reducer(state: State, action: { type: 'RESET_DELAY' } | { type: 'SHOW' } | { type: 'FEEDBACK'; payload: string | null }): State {
|
||||
if (action.type === 'RESET_DELAY') return { ...state, showAfterDelay: false };
|
||||
if (action.type === 'SHOW') return { ...state, showAfterDelay: true };
|
||||
if (action.type === 'FEEDBACK') return { ...state, feedbackMessageKey: action.payload };
|
||||
return state;
|
||||
}
|
||||
|
||||
const ErrorDisplay = ({ error }: { error: any }) => {
|
||||
const { t } = useTranslation();
|
||||
const [feedbackMessageKey, setFeedbackMessageKey] = useState<string | null>(null);
|
||||
const [showAfterDelay, setShowAfterDelay] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, { showAfterDelay: false, feedbackMessageKey: null });
|
||||
|
||||
const hasError = !!(error?.message || error?.stack || error?.details || error);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasError) return;
|
||||
const timer = setTimeout(() => setShowAfterDelay(true), 1000);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
setShowAfterDelay(false);
|
||||
};
|
||||
if (!hasError) {
|
||||
queueMicrotask(() => dispatch({ type: 'RESET_DELAY' }));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => dispatch({ type: 'SHOW' }), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hasError]);
|
||||
|
||||
if (!hasError || !showAfterDelay) {
|
||||
if (!hasError || !state.showAfterDelay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalDisplayMessage = error?.message ? `${t('error')}: ${error.message}` : typeof error === 'string' ? error : null;
|
||||
|
||||
const handleMessageClick = async () => {
|
||||
if (!error || !error.message || feedbackMessageKey) return;
|
||||
if (!error || !error.message || state.feedbackMessageKey) return;
|
||||
|
||||
const errorString = JSON.stringify(error, null, 2);
|
||||
try {
|
||||
await copyToClipboard(errorString);
|
||||
setFeedbackMessageKey('copied');
|
||||
setTimeout(() => {
|
||||
setFeedbackMessageKey(null);
|
||||
}, 1500);
|
||||
dispatch({ type: 'FEEDBACK', payload: 'copied' });
|
||||
setTimeout(() => dispatch({ type: 'FEEDBACK', payload: null }), 1500);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy error: ', err);
|
||||
setFeedbackMessageKey('failed');
|
||||
setTimeout(() => {
|
||||
setFeedbackMessageKey(null);
|
||||
}, 1500);
|
||||
dispatch({ type: 'FEEDBACK', payload: 'failed' });
|
||||
setTimeout(() => dispatch({ type: 'FEEDBACK', payload: null }), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,11 +52,11 @@ const ErrorDisplay = ({ error }: { error: any }) => {
|
||||
const classNames = [styles.errorMessage];
|
||||
let isClickable = false;
|
||||
|
||||
if (feedbackMessageKey === 'copied') {
|
||||
if (state.feedbackMessageKey === 'copied') {
|
||||
currentDisplayMessage = t('fullErrorCopiedToClipboard', 'full error copied to the clipboard');
|
||||
classNames.pop();
|
||||
classNames.push(styles.feedbackSuccessMessage);
|
||||
} else if (feedbackMessageKey === 'failed') {
|
||||
} else if (state.feedbackMessageKey === 'failed') {
|
||||
currentDisplayMessage = t('copyFailed', 'copy failed');
|
||||
} else if (originalDisplayMessage) {
|
||||
currentDisplayMessage = originalDisplayMessage;
|
||||
@@ -62,15 +66,25 @@ const ErrorDisplay = ({ error }: { error: any }) => {
|
||||
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
{currentDisplayMessage && (
|
||||
<span
|
||||
className={classNames.join(' ')}
|
||||
onClick={isClickable ? handleMessageClick : undefined}
|
||||
title={isClickable ? t('clickToCopyFullError', 'Click to copy full error') : undefined}
|
||||
>
|
||||
{currentDisplayMessage}
|
||||
</span>
|
||||
)}
|
||||
{currentDisplayMessage &&
|
||||
(isClickable ? (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames.join(' ')}
|
||||
onClick={handleMessageClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleMessageClick();
|
||||
}
|
||||
}}
|
||||
title={t('clickToCopyFullError', 'Click to copy full error')}
|
||||
>
|
||||
{currentDisplayMessage}
|
||||
</button>
|
||||
) : (
|
||||
<span className={classNames.join(' ')}>{currentDisplayMessage}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Placement } from '@floating-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown, { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import supersub from 'remark-supersub';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, shift, size, autoUpdate, Placement, FloatingPortal } from '@floating-ui/react';
|
||||
import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, shift, size, autoUpdate, FloatingPortal } from '@floating-ui/react';
|
||||
import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
||||
import { isCatalogView } from '../../lib/utils/view-utils';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
@@ -19,6 +20,14 @@ import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/s
|
||||
import { useComment } from '@plebbit/plebbit-react-hooks';
|
||||
import ReplyQuotePreview from '../reply-quote-preview';
|
||||
|
||||
const safeParseUrl = (href: string): URL | null => {
|
||||
try {
|
||||
return href.startsWith('http') ? new URL(href) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface ContentLinkEmbedProps {
|
||||
children: any;
|
||||
href: string;
|
||||
@@ -34,13 +43,13 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
|
||||
const isMobile = useIsMobile();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showMedia, setShowMedia] = useState(false);
|
||||
const placementRef = useRef<Placement>('right');
|
||||
const [placement, setPlacement] = useState<Placement>('right');
|
||||
const availableWidthRef = useRef<number>(0);
|
||||
|
||||
const { refs, floatingStyles, update, context } = useFloating({
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
placement: placementRef.current,
|
||||
placement,
|
||||
middleware: [
|
||||
shift({ padding: 10 }),
|
||||
offset({ mainAxis: 5 }),
|
||||
@@ -49,8 +58,8 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
|
||||
availableWidthRef.current = availableWidth;
|
||||
if (availableWidth >= 250) {
|
||||
elements.floating.style.maxWidth = `${availableWidth - 12}px`;
|
||||
} else if (placementRef.current === 'right') {
|
||||
placementRef.current = 'left';
|
||||
} else if (placement === 'right') {
|
||||
setPlacement('left');
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -68,9 +77,9 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
|
||||
const handleResize = () => {
|
||||
const availableWidth = availableWidthRef.current;
|
||||
if (availableWidth >= 250) {
|
||||
placementRef.current = 'right';
|
||||
setPlacement('right');
|
||||
} else {
|
||||
placementRef.current = 'left';
|
||||
setPlacement('left');
|
||||
}
|
||||
update();
|
||||
};
|
||||
@@ -87,7 +96,20 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
|
||||
{children}
|
||||
</a>{' '}
|
||||
[
|
||||
<span className={styles.embedButton} onClick={() => setShowMedia(!showMedia)} ref={refs.setReference} {...getReferenceProps()}>
|
||||
<span
|
||||
className={styles.embedButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowMedia(!showMedia);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowMedia(!showMedia)}
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{showMedia ? t('remove') : isMobile ? t('open') : t('embed')}
|
||||
</span>
|
||||
]
|
||||
@@ -303,13 +325,16 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => {
|
||||
spoiler: ({ children }) => <span className='spoilertext'>{children}</span>,
|
||||
a: ({ href, children }) => {
|
||||
if (href && !isInCatalogView) {
|
||||
try {
|
||||
const linkMediaInfo = getLinkMediaInfo(href);
|
||||
const embedUrl = href.startsWith('http') ? new URL(href) : null;
|
||||
if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) {
|
||||
return <ContentLinkEmbed children={children} href={href} linkMediaInfo={linkMediaInfo} />;
|
||||
}
|
||||
} catch (e) {
|
||||
const linkMediaInfo = getLinkMediaInfo(href);
|
||||
const embedUrl = safeParseUrl(href);
|
||||
if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) {
|
||||
return (
|
||||
<ContentLinkEmbed href={href} linkMediaInfo={linkMediaInfo}>
|
||||
{children}
|
||||
</ContentLinkEmbed>
|
||||
);
|
||||
}
|
||||
if (!embedUrl && href.startsWith('http')) {
|
||||
console.debug('Invalid URL:', href);
|
||||
}
|
||||
|
||||
@@ -330,7 +355,9 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => {
|
||||
{content ? ': ' : ''}
|
||||
</span>
|
||||
)}
|
||||
<ReactMarkdown children={processedContent} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components} />
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components}>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||
@@ -102,7 +102,7 @@ const PostInfo = ({
|
||||
const { showOmittedReplies } = useShowOmittedReplies();
|
||||
const directories = useDirectories();
|
||||
const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined;
|
||||
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
|
||||
const postMenuProps = selectPostMenuProps(post);
|
||||
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
@@ -219,16 +219,14 @@ const PostInfo = ({
|
||||
const showUserID = pseudonymityMode !== 'per-reply';
|
||||
|
||||
const handleUserAddressClick = useAuthorAddressClick();
|
||||
const numberOfPostsByAuthor = useMemo(() => {
|
||||
const numberOfPostsByAuthor = (() => {
|
||||
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const domCount = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
|
||||
// DOM-based count can be 0 on initial mount (before commit) or when parent isn't in DOM yet (e.g. Virtuoso board feed).
|
||||
// The current post is always at least 1 when we're displaying it.
|
||||
return Math.max(domCount, 1);
|
||||
}, [showUserID, deleted, removed, shortAddress, postCid, postReplyCount]);
|
||||
})();
|
||||
|
||||
const { hidden } = useHide(post);
|
||||
|
||||
@@ -254,10 +252,9 @@ const PostInfo = ({
|
||||
(title.length <= 75 ? (
|
||||
<span className={styles.subject}>{title} </span>
|
||||
) : (
|
||||
<Tooltip
|
||||
children={<span className={styles.subject}>{title.slice(0, 75) + '(...)'} </span>}
|
||||
content={title.length < 1000 ? title : title.slice(0, 1000) + `... ${t('title_too_long')}`}
|
||||
/>
|
||||
<Tooltip content={title.length < 1000 ? title : title.slice(0, 1000) + `... ${t('title_too_long')}`}>
|
||||
<span className={styles.subject}>{title.slice(0, 75) + '(...)'} </span>
|
||||
</Tooltip>
|
||||
))}
|
||||
<span className={styles.nameBlock}>
|
||||
<span className={`${styles.name} ${authorRole && !(deleted || removed) && (authorRole === 'mod' ? styles.capcodeMod : styles.capcodeAdmin)}`}>
|
||||
@@ -269,10 +266,9 @@ const PostInfo = ({
|
||||
displayName.length <= 20 ? (
|
||||
displayName
|
||||
) : (
|
||||
<Tooltip
|
||||
children={displayName.slice(0, 20) + '(...)'}
|
||||
content={displayName.length < 1000 ? displayName : displayName.slice(0, 1000) + `... ${t('display_name_too_long')}`}
|
||||
/>
|
||||
<Tooltip content={displayName.length < 1000 ? displayName : displayName.slice(0, 1000) + `... ${t('display_name_too_long')}`}>
|
||||
{displayName.slice(0, 20) + '(...)'}
|
||||
</Tooltip>
|
||||
)
|
||||
) : (
|
||||
capitalize(t('anonymous'))
|
||||
@@ -299,19 +295,26 @@ const PostInfo = ({
|
||||
<span className={styles.pendingCid}>{hasFailedState ? capitalize(t('failed')) : capitalize(t('pending'))}</span>
|
||||
) : (
|
||||
<Tooltip
|
||||
children={
|
||||
<span
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{formatUserIDForDisplay(userID)}
|
||||
</span>
|
||||
}
|
||||
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
|
||||
showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleUserAddressClick(userID, postCid);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{formatUserIDForDisplay(userID)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
){' '}
|
||||
</>
|
||||
@@ -320,11 +323,15 @@ const PostInfo = ({
|
||||
<span className={styles.dateTime}>
|
||||
{isInModQueueView && isOverThreshold ? (
|
||||
<>
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} /> (
|
||||
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
|
||||
<Tooltip content={getFormattedTimeAgo(timestamp)}>
|
||||
<span>{getFormattedDate(timestamp)}</span>
|
||||
</Tooltip>{' '}
|
||||
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
|
||||
</>
|
||||
) : (
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
|
||||
<Tooltip content={getFormattedTimeAgo(timestamp)}>
|
||||
<span>{getFormattedDate(timestamp)}</span>
|
||||
</Tooltip>
|
||||
)}{' '}
|
||||
</span>
|
||||
<span className={styles.postNum}>
|
||||
@@ -338,7 +345,19 @@ const PostInfo = ({
|
||||
>
|
||||
No.
|
||||
</Link>
|
||||
<span className={styles.replyToPost} title={t('reply_to_post')} onMouseDown={onReplyModalClick}>
|
||||
<span
|
||||
className={styles.replyToPost}
|
||||
title={t('reply_to_post')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseDown={onReplyModalClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onReplyModalClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{post?.number || '?'}
|
||||
</span>
|
||||
</span>
|
||||
@@ -460,16 +479,18 @@ const ReplyBacklinks = ({
|
||||
return (
|
||||
<>
|
||||
{directReplies.map(
|
||||
(reply: Comment, index: number) =>
|
||||
reply?.parentCid === cid && reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
(reply: Comment) =>
|
||||
reply?.parentCid === cid &&
|
||||
reply?.cid &&
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={reply.cid} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
)}
|
||||
{quotedByMap
|
||||
?.get(cid)
|
||||
?.map(
|
||||
(reply: Comment, index: number) =>
|
||||
(reply: Comment) =>
|
||||
reply?.parentCid !== cid &&
|
||||
reply?.cid &&
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${index}`} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={reply.cid} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -556,7 +577,18 @@ const PostMedia = ({
|
||||
{!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
||||
<span>
|
||||
-[
|
||||
<span className={styles.closeMedia} onClick={() => setShowThumbnail(true)}>
|
||||
<span
|
||||
className={styles.closeMedia}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => setShowThumbnail(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('close')}
|
||||
</span>
|
||||
]
|
||||
@@ -565,7 +597,18 @@ const PostMedia = ({
|
||||
{showThumbnail && !hasThumbnail && embedUrl && canEmbed(embedUrl) && (
|
||||
<span>
|
||||
-[
|
||||
<span className={styles.closeMedia} onClick={() => setShowThumbnail(false)}>
|
||||
<span
|
||||
className={styles.closeMedia}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('open')}
|
||||
</span>
|
||||
]
|
||||
@@ -784,8 +827,8 @@ const PostDesktop = ({
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||
|
||||
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
|
||||
const filteredReplies = useMemo(() => repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [repliesForRender]);
|
||||
const directRepliesByParentCid = useMemo(() => {
|
||||
const filteredReplies = repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount)));
|
||||
const directRepliesByParentCid = (() => {
|
||||
const map = new Map<string, Comment[]>();
|
||||
for (const reply of filteredReplies) {
|
||||
const directParentCid = reply?.parentCid;
|
||||
@@ -800,7 +843,7 @@ const PostDesktop = ({
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [filteredReplies]);
|
||||
})();
|
||||
|
||||
const quotedByMap = useQuotedByMap(filteredReplies);
|
||||
|
||||
@@ -856,7 +899,18 @@ const PostDesktop = ({
|
||||
<div className={isHidden ? styles.postDesktopHidden : ''}>
|
||||
{!isInPostPageView && showReplies && (
|
||||
<span className={`${styles.hideButtonWrapper} ${!hasThumbnail ? styles.hideButtonWrapperNoImage : ''}`}>
|
||||
<span className={`${styles.hideButton} ${hidden ? styles.unhideThread : styles.hideThread}`} onClick={hidden ? unhide : hide} />
|
||||
<span
|
||||
className={`${styles.hideButton} ${hidden ? styles.unhideThread : styles.hideThread}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={hidden ? unhide : hide}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(hidden ? unhide : hide)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
@@ -911,7 +965,15 @@ const PostDesktop = ({
|
||||
<span className={styles.summary}>
|
||||
<span
|
||||
className={`${showOmittedReplies[cid] ? styles.hideOmittedReplies : styles.showOmittedReplies} ${styles.omittedRepliesButtonWrapper}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => setShowOmittedReplies(cid, !showOmittedReplies[cid])}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowOmittedReplies(cid, !showOmittedReplies[cid]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showOmittedReplies[cid] ? (
|
||||
t('showing_all_replies')
|
||||
@@ -964,8 +1026,8 @@ const PostDesktop = ({
|
||||
!isInPendingPostView &&
|
||||
showReplies &&
|
||||
!hasMore &&
|
||||
visibleReplies.map((reply, index) => (
|
||||
<div key={index} className={styles.replyContainer}>
|
||||
visibleReplies.map((reply) => (
|
||||
<div key={reply.cid} className={styles.replyContainer}>
|
||||
<Reply
|
||||
reply={reply}
|
||||
roles={roles}
|
||||
@@ -983,8 +1045,8 @@ const PostDesktop = ({
|
||||
!isInPendingPostView &&
|
||||
repliesForRender &&
|
||||
showReplies &&
|
||||
filteredReplies.map((reply, index) => (
|
||||
<div key={index} className={styles.replyContainer}>
|
||||
filteredReplies.map((reply) => (
|
||||
<div key={reply.cid} className={styles.replyContainer}>
|
||||
<Reply
|
||||
reply={reply}
|
||||
roles={roles}
|
||||
|
||||
@@ -14,6 +14,30 @@ import useHide from '../../../hooks/use-hide';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { PostMenuProps } from '../../../lib/utils/post-menu-props';
|
||||
|
||||
const safeCopyShareLink = async (boardIdentifier: string, linkType: ShareLinkType, cid?: string): Promise<boolean> => {
|
||||
try {
|
||||
if (linkType === 'thread' && cid) {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
|
||||
} else {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType as Exclude<ShareLinkType, 'thread'>);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const safeCopyToClipboard = async (text: string, label: string): Promise<boolean> => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${label}`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type CopyLinkButtonProps =
|
||||
| { cid: string; subplebbitAddress: string; linkType: 'thread'; onClose: () => void }
|
||||
| { subplebbitAddress: string; linkType: Exclude<ShareLinkType, 'thread'>; onClose: () => void; cid?: undefined };
|
||||
@@ -22,61 +46,72 @@ const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkB
|
||||
const { t } = useTranslation();
|
||||
const directories = useDirectories();
|
||||
const boardIdentifier = getBoardPath(subplebbitAddress, directories);
|
||||
const handleClick = async () => {
|
||||
await safeCopyShareLink(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (linkType === 'thread') {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
|
||||
} else {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link', error);
|
||||
} finally {
|
||||
onClose();
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>{t('copy_direct_link')}</div>
|
||||
{t('copy_direct_link')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
await safeCopyToClipboard(cid, 'content id');
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyToClipboard(cid);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy content id', error);
|
||||
} finally {
|
||||
onClose();
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>{t('copy_content_id')}</div>
|
||||
{t('copy_content_id')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
await safeCopyToClipboard(address, 'user id');
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyToClipboard(address);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy user id', error);
|
||||
} finally {
|
||||
onClose();
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>{t('copy_user_id')}</div>
|
||||
{t('copy_user_id')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -93,10 +128,18 @@ const ImageSearchButton = ({ url, onClose }: { url: string; onClose: () => void
|
||||
return (
|
||||
<div
|
||||
className={`${styles.postMenuItem} ${styles.dropdown}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseOver={() => setIsImageSearchMenuOpen(true)}
|
||||
onMouseLeave={() => setIsImageSearchMenuOpen(false)}
|
||||
ref={refs.setReference}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{capitalize(t('image_search'))} »
|
||||
{isImageSearchMenuOpen && (
|
||||
@@ -163,7 +206,15 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
<span
|
||||
className={isInCatalogView ? styles.postMenuBtnCatalog : styles.postMenuBtn}
|
||||
title='Post menu'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleMenuClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleMenuClick();
|
||||
}
|
||||
}}
|
||||
style={{ transform: menuBtnRotated && cid ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
▶
|
||||
@@ -180,10 +231,19 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
{!(isInPostPageView && postCid === cid) && (
|
||||
<div
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
hidden ? unhide() : hide();
|
||||
handleClose();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
hidden ? unhide() : hide();
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,252 @@ export const LinkTypePreviewer = ({ link }: { link: string }) => {
|
||||
return isValidURL(link) ? type : t('invalid_url');
|
||||
};
|
||||
|
||||
const PostFormActions = ({
|
||||
variant,
|
||||
t,
|
||||
isInPostView,
|
||||
onPublishReply,
|
||||
onPublishPost,
|
||||
handleUpload,
|
||||
isUploading,
|
||||
showUploadControls,
|
||||
}: {
|
||||
variant: 'reply' | 'post' | 'upload';
|
||||
t: (key: string) => string;
|
||||
isInPostView: boolean;
|
||||
onPublishReply: () => void;
|
||||
onPublishPost: () => void;
|
||||
handleUpload: () => void;
|
||||
isUploading: boolean;
|
||||
showUploadControls: boolean;
|
||||
}) => {
|
||||
if (variant === 'reply' && isInPostView) {
|
||||
return (
|
||||
<button onClick={onPublishReply} disabled={isUploading}>
|
||||
{t('post')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (variant === 'post' && !isInPostView) {
|
||||
return <button onClick={onPublishPost}>{t('post')}</button>;
|
||||
}
|
||||
if (variant === 'upload' && showUploadControls) {
|
||||
return (
|
||||
<button onClick={handleUpload} disabled={isUploading}>
|
||||
{t('choose_file')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
interface PostFormFieldsProps {
|
||||
t: (key: string) => string;
|
||||
account: ReturnType<typeof useAccount>;
|
||||
displayName: string | undefined;
|
||||
isInPostView: boolean;
|
||||
subjectRef: React.Ref<HTMLInputElement>;
|
||||
textRef: React.Ref<HTMLTextAreaElement>;
|
||||
urlRef: React.Ref<HTMLInputElement>;
|
||||
url: string;
|
||||
lengthError: string | null;
|
||||
handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
setPublishPostOptions: (opts: Record<string, unknown>) => void;
|
||||
setPublishReplyOptions: (opts: Record<string, unknown>) => void;
|
||||
setUrl: (url: string) => void;
|
||||
isUploading: boolean;
|
||||
uploadedFileName: string | null | undefined;
|
||||
showUploadControls: boolean;
|
||||
showSpoilerForPost: boolean;
|
||||
showSpoilerForReply: boolean;
|
||||
isInAllView: boolean;
|
||||
isInSubscriptionsView: boolean;
|
||||
isInModView: boolean;
|
||||
directories: ReturnType<typeof useDirectories>;
|
||||
accountSubplebbitAddresses: string[];
|
||||
subscriptions: string[];
|
||||
subplebbitAddress: string | undefined;
|
||||
onPublishReply: () => void;
|
||||
onPublishPost: () => void;
|
||||
handleUpload: () => void;
|
||||
}
|
||||
|
||||
const PostFormFields = ({
|
||||
t,
|
||||
account,
|
||||
displayName,
|
||||
isInPostView,
|
||||
subjectRef,
|
||||
textRef,
|
||||
urlRef,
|
||||
url,
|
||||
lengthError,
|
||||
handleContentChange,
|
||||
setPublishPostOptions,
|
||||
setPublishReplyOptions,
|
||||
setUrl,
|
||||
isUploading,
|
||||
uploadedFileName,
|
||||
showUploadControls,
|
||||
showSpoilerForPost,
|
||||
showSpoilerForReply,
|
||||
isInAllView,
|
||||
isInSubscriptionsView,
|
||||
isInModView,
|
||||
directories,
|
||||
accountSubplebbitAddresses,
|
||||
subscriptions,
|
||||
subplebbitAddress,
|
||||
onPublishReply,
|
||||
onPublishPost,
|
||||
handleUpload,
|
||||
}: PostFormFieldsProps) => (
|
||||
<>
|
||||
<tr>
|
||||
<td>{t('name')}</td>
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={!displayName ? capitalize(t('anonymous')) : undefined}
|
||||
defaultValue={displayName || undefined}
|
||||
onChange={(e) => {
|
||||
const newDisplayName = e.target.value.trim() || undefined;
|
||||
setAccount({ ...account, author: { ...account?.author, displayName: newDisplayName } });
|
||||
if (isInPostView) {
|
||||
setPublishReplyOptions({ displayName: newDisplayName });
|
||||
} else {
|
||||
setPublishPostOptions({ displayName: newDisplayName });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PostFormActions
|
||||
variant='reply'
|
||||
t={t}
|
||||
isInPostView={isInPostView}
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{!isInPostView && (
|
||||
<tr>
|
||||
<td>{t('subject')}</td>
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
ref={subjectRef}
|
||||
onChange={(e) => {
|
||||
setPublishPostOptions({ title: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<PostFormActions
|
||||
variant='post'
|
||||
t={t}
|
||||
isInPostView={isInPostView}
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('comment')}</td>
|
||||
<td>
|
||||
<textarea cols={48} rows={4} wrap='soft' ref={textRef} onChange={handleContentChange} />
|
||||
{lengthError && <div className={styles.error}>{lengthError}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('link')}</td>
|
||||
<td className={styles.linkField}>
|
||||
<input
|
||||
type='text'
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
ref={urlRef}
|
||||
disabled={isUploading}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
isInPostView ? setPublishReplyOptions({ link: e.target.value }) : setPublishPostOptions({ link: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{showUploadControls && (
|
||||
<tr className={styles.uploadButton}>
|
||||
<td>{t('file')}</td>
|
||||
<td>
|
||||
<PostFormActions
|
||||
variant='upload'
|
||||
t={t}
|
||||
isInPostView={isInPostView}
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
||||
<tr className={styles.spoilerButton}>
|
||||
<td>{t('options')}</td>
|
||||
<td>
|
||||
[
|
||||
<label>
|
||||
<input
|
||||
type='checkbox'
|
||||
onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))}
|
||||
/>
|
||||
{capitalize(t('spoiler'))}?
|
||||
</label>
|
||||
]
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(isInAllView || isInSubscriptionsView || isInModView) && (
|
||||
<tr>
|
||||
<td>{t('board')}</td>
|
||||
<td>
|
||||
<select onChange={(e) => setPublishPostOptions({ subplebbitAddress: e.target.value })} value={subplebbitAddress}>
|
||||
<option value=''>{t('choose_one')}</option>
|
||||
{isInAllView &&
|
||||
directories
|
||||
.filter((subplebbit) => subplebbit.title && subplebbit.address)
|
||||
.map((subplebbit) => (
|
||||
<option key={subplebbit.address} value={subplebbit.address}>
|
||||
{subplebbit.title}
|
||||
</option>
|
||||
))}
|
||||
{isInModView &&
|
||||
accountSubplebbitAddresses.map((address: string) => (
|
||||
<option key={address} value={address}>
|
||||
{address && Plebbit.getShortAddress({ address })}
|
||||
</option>
|
||||
))}
|
||||
{isInSubscriptionsView &&
|
||||
subscriptions.map((sub: string) => (
|
||||
<option key={sub} value={sub}>
|
||||
{sub}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
@@ -230,127 +476,36 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
return (
|
||||
<table className={styles.postFormTable}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('name')}</td>
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={!displayName ? capitalize(t('anonymous')) : undefined}
|
||||
defaultValue={displayName || undefined}
|
||||
onChange={(e) => {
|
||||
const newDisplayName = e.target.value.trim() || undefined;
|
||||
setAccount({ ...account, author: { ...account?.author, displayName: newDisplayName } });
|
||||
if (isInPostView) {
|
||||
setPublishReplyOptions({ displayName: newDisplayName });
|
||||
} else {
|
||||
setPublishPostOptions({ displayName: newDisplayName });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isInPostView && (
|
||||
<button onClick={onPublishReply} disabled={isUploading}>
|
||||
{t('post')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{!isInPostView && (
|
||||
<tr>
|
||||
<td>{t('subject')}</td>
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
ref={subjectRef}
|
||||
onChange={(e) => {
|
||||
setPublishPostOptions({ title: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<button onClick={onPublishPost}>{t('post')}</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>{t('comment')}</td>
|
||||
<td>
|
||||
<textarea cols={48} rows={4} wrap='soft' ref={textRef} onChange={handleContentChange} />
|
||||
{lengthError && <div className={styles.error}>{lengthError}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('link')}</td>
|
||||
<td className={styles.linkField}>
|
||||
<input
|
||||
type='text'
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
ref={urlRef}
|
||||
disabled={isUploading}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
isInPostView ? setPublishReplyOptions({ link: e.target.value }) : setPublishPostOptions({ link: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{showUploadControls && (
|
||||
<tr className={styles.uploadButton}>
|
||||
<td>{t('file')}</td>
|
||||
<td>
|
||||
<button onClick={handleUpload} disabled={isUploading}>
|
||||
{t('choose_file')}
|
||||
</button>
|
||||
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
||||
<tr className={styles.spoilerButton}>
|
||||
<td>{t('options')}</td>
|
||||
<td>
|
||||
[
|
||||
<label>
|
||||
<input
|
||||
type='checkbox'
|
||||
onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))}
|
||||
/>
|
||||
{capitalize(t('spoiler'))}?
|
||||
</label>
|
||||
]
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(isInAllView || isInSubscriptionsView || isInModView) && (
|
||||
<tr>
|
||||
<td>{t('board')}</td>
|
||||
<td>
|
||||
<select onChange={(e) => setPublishPostOptions({ subplebbitAddress: e.target.value })} value={subplebbitAddress}>
|
||||
<option value=''>{t('choose_one')}</option>
|
||||
{isInAllView &&
|
||||
directories
|
||||
.filter((subplebbit) => subplebbit.title && subplebbit.address)
|
||||
.map((subplebbit) => (
|
||||
<option key={subplebbit.address} value={subplebbit.address}>
|
||||
{subplebbit.title}
|
||||
</option>
|
||||
))}
|
||||
{isInModView &&
|
||||
accountSubplebbitAddresses.map((address: string) => (
|
||||
<option key={address} value={address}>
|
||||
{address && Plebbit.getShortAddress({ address })}
|
||||
</option>
|
||||
))}
|
||||
{isInSubscriptionsView &&
|
||||
subscriptions.map((sub: string) => (
|
||||
<option key={sub} value={sub}>
|
||||
{sub}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<PostFormFields
|
||||
t={t}
|
||||
account={account}
|
||||
displayName={displayName}
|
||||
isInPostView={isInPostView}
|
||||
subjectRef={subjectRef}
|
||||
textRef={textRef}
|
||||
urlRef={urlRef}
|
||||
url={url}
|
||||
lengthError={lengthError}
|
||||
handleContentChange={handleContentChange}
|
||||
setPublishPostOptions={setPublishPostOptions}
|
||||
setPublishReplyOptions={setPublishReplyOptions}
|
||||
setUrl={setUrl}
|
||||
isUploading={isUploading}
|
||||
uploadedFileName={uploadedFileName}
|
||||
showUploadControls={showUploadControls}
|
||||
showSpoilerForPost={showSpoilerForPost}
|
||||
showSpoilerForReply={showSpoilerForReply}
|
||||
isInAllView={isInAllView}
|
||||
isInSubscriptionsView={isInSubscriptionsView}
|
||||
isInModView={isInModView}
|
||||
directories={directories}
|
||||
accountSubplebbitAddresses={accountSubplebbitAddresses}
|
||||
subscriptions={subscriptions}
|
||||
subplebbitAddress={subplebbitAddress}
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,34 @@ import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { PostMenuProps } from '../../../lib/utils/post-menu-props';
|
||||
|
||||
async function copyShareLinkSafe(boardIdentifier: string, linkType: ShareLinkType, cid?: string): Promise<void> {
|
||||
try {
|
||||
if (linkType === 'thread' && cid) {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
|
||||
} else {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType as Exclude<ShareLinkType, 'thread'>);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyContentIdSafe(cid: string): Promise<void> {
|
||||
try {
|
||||
await copyToClipboard(cid);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy content id', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyUserIdSafe(address: string): Promise<void> {
|
||||
try {
|
||||
await copyToClipboard(address);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy user id', error);
|
||||
}
|
||||
}
|
||||
|
||||
type HideButtonProps = {
|
||||
cid?: string;
|
||||
isReply?: boolean;
|
||||
@@ -31,19 +59,19 @@ const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkB
|
||||
const { t } = useTranslation();
|
||||
const directories = useDirectories();
|
||||
const boardIdentifier = getBoardPath(subplebbitAddress, directories);
|
||||
const handleClick = async () => {
|
||||
await copyShareLinkSafe(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (linkType === 'thread') {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
|
||||
} else {
|
||||
await copyShareLinkToClipboard(boardIdentifier, linkType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy share link', error);
|
||||
} finally {
|
||||
onClose();
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -54,15 +82,19 @@ const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkB
|
||||
|
||||
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
await copyContentIdSafe(cid);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyToClipboard(cid);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy content id', error);
|
||||
} finally {
|
||||
onClose();
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -73,15 +105,19 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
|
||||
const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
await copyUserIdSafe(address);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyToClipboard(address);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy user id', error);
|
||||
} finally {
|
||||
onClose();
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -93,7 +129,17 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
|
||||
const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div onClick={onClose}>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<a href={`https://lens.google.com/uploadbyurl?url=${url}`} target='_blank' rel='noreferrer'>
|
||||
<div className={styles.postMenuItem}>{t('search_image_on_google')}</div>
|
||||
</a>
|
||||
@@ -112,12 +158,21 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
|
||||
const { hide, hidden, unhide } = useHide({ cid: cid || '' });
|
||||
const isInPostView = isPostPageView(useLocation().pathname, useParams());
|
||||
|
||||
const handleClick = () => {
|
||||
hidden ? unhide() : hide();
|
||||
onClose && onClose();
|
||||
};
|
||||
return (
|
||||
(!isInPostView || isReply) && (
|
||||
<div
|
||||
onClick={() => {
|
||||
hidden ? unhide() : hide();
|
||||
onClose && onClose();
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>
|
||||
@@ -169,7 +224,21 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
|
||||
<>
|
||||
{!(deleted || removed) && (
|
||||
<>
|
||||
<span className={styles.postMenuBtn} title='Post menu' onClick={handleMenuClick} ref={refs.setReference} {...getReferenceProps()}>
|
||||
<span
|
||||
className={styles.postMenuBtn}
|
||||
title='Post menu'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleMenuClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleMenuClick();
|
||||
}
|
||||
}}
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
...
|
||||
</span>
|
||||
{isMenuOpen &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||
@@ -187,22 +187,20 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
||||
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds;
|
||||
|
||||
const hasFailedState = state === 'failed';
|
||||
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
|
||||
const postMenuProps = selectPostMenuProps(post);
|
||||
|
||||
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
|
||||
const showUserID = pseudonymityMode !== 'per-reply';
|
||||
|
||||
const handleUserAddressClick = useAuthorAddressClick();
|
||||
const numberOfPostsByAuthor = useMemo(() => {
|
||||
const numberOfPostsByAuthor = (() => {
|
||||
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const domCount = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
|
||||
// DOM-based count can be 0 on initial mount (before commit) or when parent isn't in DOM yet (e.g. Virtuoso board feed).
|
||||
// The current post is always at least 1 when we're displaying it.
|
||||
return Math.max(domCount, 1);
|
||||
}, [showUserID, deleted, removed, shortAddress, postCid, postReplyCount]);
|
||||
})();
|
||||
|
||||
const userID = address && Plebbit.getShortAddress({ address }); // shortened to 8 chars for display; users can verify the full user ID via "Copy user ID" in the post menu to guard against spoofing
|
||||
const userIDBackgroundColor = hashStringToColor(userID);
|
||||
@@ -239,10 +237,9 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
||||
displayName.length <= 20 ? (
|
||||
displayName
|
||||
) : (
|
||||
<Tooltip
|
||||
children={displayName.slice(0, 20) + '(...)'}
|
||||
content={displayName.length < 1000 ? displayName : displayName.slice(0, 1000) + `... ${t('display_name_too_long')}`}
|
||||
/>
|
||||
<Tooltip content={displayName.length < 1000 ? displayName : displayName.slice(0, 1000) + `... ${t('display_name_too_long')}`}>
|
||||
{displayName.slice(0, 20) + '(...)'}
|
||||
</Tooltip>
|
||||
)
|
||||
) : (
|
||||
capitalize(t('anonymous'))
|
||||
@@ -272,19 +269,26 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
||||
<span className={styles.pendingCid}>{hasFailedState ? capitalize(t('failed')) : capitalize(t('pending'))}</span>
|
||||
) : (
|
||||
<Tooltip
|
||||
children={
|
||||
<span
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{formatUserIDForDisplay(userID)}
|
||||
</span>
|
||||
}
|
||||
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
|
||||
showTooltip={isInPostPageView || postReplyCount < 6}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleUserAddressClick(userID, postCid);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{formatUserIDForDisplay(userID)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
){' '}
|
||||
</>
|
||||
@@ -304,10 +308,9 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
||||
{title.length <= 30 ? (
|
||||
<span className={styles.subject}>{title}</span>
|
||||
) : (
|
||||
<Tooltip
|
||||
children={<span className={styles.subject}>{title.slice(0, 30) + '(...)'}</span>}
|
||||
content={title.length < 1000 ? title : title.slice(0, 1000) + `... ${t('title_too_long')}`}
|
||||
/>
|
||||
<Tooltip content={title.length < 1000 ? title : title.slice(0, 1000) + `... ${t('title_too_long')}`}>
|
||||
<span className={styles.subject}>{title.slice(0, 30) + '(...)'}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -321,11 +324,15 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
||||
)}
|
||||
{isInModQueueView && isOverThreshold ? (
|
||||
<>
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} /> (
|
||||
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
|
||||
<Tooltip content={getFormattedTimeAgo(timestamp)}>
|
||||
<span>{getFormattedDate(timestamp)}</span>
|
||||
</Tooltip>{' '}
|
||||
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
|
||||
</>
|
||||
) : (
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
|
||||
<Tooltip content={getFormattedTimeAgo(timestamp)}>
|
||||
<span>{getFormattedDate(timestamp)}</span>
|
||||
</Tooltip>
|
||||
)}{' '}
|
||||
{cid ? (
|
||||
<span className={styles.postNumLink}>
|
||||
@@ -337,7 +344,19 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
||||
>
|
||||
No.
|
||||
</Link>
|
||||
<span className={styles.replyToPost} title={t('reply_to_post')} onMouseDown={onReplyModalClick}>
|
||||
<span
|
||||
className={styles.replyToPost}
|
||||
title={t('reply_to_post')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseDown={onReplyModalClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onReplyModalClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{post?.number || '?'}
|
||||
</span>
|
||||
</span>
|
||||
@@ -425,16 +444,18 @@ const ReplyBacklinks = ({ post, quotedByMap, directRepliesByParentCid }: ReplyBa
|
||||
const replyBacklinks = cid && parentCid && (directReplies.length > 0 || quotedByMap?.get(cid)?.length) && (
|
||||
<>
|
||||
{directReplies.map(
|
||||
(reply: Comment, index: number) =>
|
||||
reply?.parentCid === cid && reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
(reply: Comment) =>
|
||||
reply?.parentCid === cid &&
|
||||
reply?.cid &&
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={reply.cid} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
)}
|
||||
{quotedByMap
|
||||
?.get(cid)
|
||||
?.map(
|
||||
(reply: Comment, index: number) =>
|
||||
(reply: Comment) =>
|
||||
reply?.parentCid !== cid &&
|
||||
reply?.cid &&
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${index}`} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${reply.cid}`} isBacklinkReply={true} backlinkReply={reply} />,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -564,9 +585,9 @@ const PostMobile = ({
|
||||
const hasFailedState = state === 'failed';
|
||||
|
||||
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
|
||||
const filteredReplies = useMemo(() => repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [repliesForRender]);
|
||||
const filteredReplies = repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount)));
|
||||
|
||||
const directRepliesByParentCid = useMemo(() => {
|
||||
const directRepliesByParentCid = (() => {
|
||||
const map = new Map<string, Comment[]>();
|
||||
for (const reply of filteredReplies) {
|
||||
const directParentCid = reply?.parentCid;
|
||||
@@ -579,7 +600,7 @@ const PostMobile = ({
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [filteredReplies]);
|
||||
})();
|
||||
|
||||
const quotedByMap = useQuotedByMap(filteredReplies);
|
||||
|
||||
@@ -629,7 +650,18 @@ const PostMobile = ({
|
||||
<>
|
||||
<hr className={styles.unhideButtonHr} />
|
||||
<span className={styles.mobileUnhideButton}>
|
||||
<span className='button' onClick={unhide}>
|
||||
<span
|
||||
className='button'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={unhide}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
unhide();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Show Hidden Thread
|
||||
</span>
|
||||
</span>
|
||||
@@ -724,8 +756,8 @@ const PostMobile = ({
|
||||
!isInPendingPostView &&
|
||||
showReplies &&
|
||||
!hasMore &&
|
||||
visibleReplies.map((reply, index) => (
|
||||
<div key={index} className={styles.replyContainer}>
|
||||
visibleReplies.map((reply) => (
|
||||
<div key={reply.cid} className={styles.replyContainer}>
|
||||
<Reply
|
||||
postReplyCount={replyCount}
|
||||
reply={reply}
|
||||
@@ -742,8 +774,8 @@ const PostMobile = ({
|
||||
!isInPendingPostView &&
|
||||
repliesForRender &&
|
||||
showReplies &&
|
||||
filteredReplies.slice(-BOARD_REPLIES_PREVIEW_VISIBLE_COUNT).map((reply, index) => (
|
||||
<div key={index} className={styles.replyContainer}>
|
||||
filteredReplies.slice(-BOARD_REPLIES_PREVIEW_VISIBLE_COUNT).map((reply) => (
|
||||
<div key={reply.cid} className={styles.replyContainer}>
|
||||
<Reply
|
||||
postReplyCount={replyCount}
|
||||
reply={reply}
|
||||
|
||||
@@ -58,7 +58,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lengthError, setLengthError] = useState<string | null>(null);
|
||||
|
||||
const checkContentLength = useRef(
|
||||
const checkContentLengthRef = useRef(
|
||||
debounce((content: string, t: Function) => {
|
||||
const length = content.trim().length;
|
||||
if (length > 2000) {
|
||||
@@ -68,7 +68,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
setLengthError(null);
|
||||
}
|
||||
}, 1000),
|
||||
).current;
|
||||
);
|
||||
|
||||
const onPublishReply = () => {
|
||||
const currentContent = textRef.current?.value.trim() || '';
|
||||
@@ -84,7 +84,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
return;
|
||||
}
|
||||
|
||||
checkContentLength.cancel();
|
||||
checkContentLengthRef.current.cancel();
|
||||
setLengthError(null);
|
||||
|
||||
if (currentContent.length > 2000) {
|
||||
@@ -202,7 +202,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
lastSelectionEndRef.current = len;
|
||||
const formattedContent = formatMarkdown(textRef.current.value);
|
||||
setPublishReplyOptions({ content: formattedContent });
|
||||
checkContentLength(formattedContent, t);
|
||||
checkContentLengthRef.current(formattedContent, t);
|
||||
|
||||
setTimeout(() => {
|
||||
if (textRef.current) {
|
||||
@@ -220,7 +220,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const formattedContent = formatMarkdown(e.target.value);
|
||||
setPublishReplyOptions({ content: formattedContent });
|
||||
checkContentLength(formattedContent, t);
|
||||
checkContentLengthRef.current(formattedContent, t);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -264,8 +264,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
|
||||
const formattedContent = formatMarkdown(nextValue);
|
||||
setPublishReplyOptions({ content: formattedContent });
|
||||
checkContentLength(formattedContent, t);
|
||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, checkContentLength, t]);
|
||||
checkContentLengthRef.current(formattedContent, t);
|
||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]);
|
||||
|
||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
||||
onUploadComplete: (uploadedUrl: string) => {
|
||||
|
||||
@@ -74,22 +74,22 @@ const scrollToThreadCardTop = (threadCid: string) => {
|
||||
const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP, showTrailingBreak = true }: ReplyQuotePreviewProps) => {
|
||||
const [hoveredCid, setHoveredCid] = useState<string | null>(null);
|
||||
const [outOfViewCid, setOutOfViewCid] = useState<string | null>(null);
|
||||
const placementRef = useRef<Placement>('right');
|
||||
const [placement, setPlacement] = useState<Placement>('right');
|
||||
const availableWidthRef = useRef<number>(0);
|
||||
const directories = useDirectories();
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: placementRef.current,
|
||||
placement,
|
||||
middleware: [
|
||||
shift({ padding: 10 }),
|
||||
offset({ mainAxis: placementRef.current === 'right' ? 8 : 4 }),
|
||||
offset({ mainAxis: placement === 'right' ? 8 : 4 }),
|
||||
size({
|
||||
apply({ availableWidth, elements }) {
|
||||
availableWidthRef.current = availableWidth;
|
||||
if (availableWidth >= 250) {
|
||||
elements.floating.style.maxWidth = `${availableWidth - 12}px`;
|
||||
} else if (placementRef.current === 'right') {
|
||||
placementRef.current = 'left';
|
||||
} else if (placement === 'right') {
|
||||
setPlacement('left');
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -101,9 +101,9 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i
|
||||
const handleResize = () => {
|
||||
const availableWidth = availableWidthRef.current;
|
||||
if (availableWidth >= 250) {
|
||||
placementRef.current = 'right';
|
||||
setPlacement('right');
|
||||
} else {
|
||||
placementRef.current = 'left';
|
||||
setPlacement('left');
|
||||
}
|
||||
update();
|
||||
};
|
||||
|
||||
@@ -8,6 +8,23 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const isAndroid = Capacitor.getPlatform() === 'android';
|
||||
|
||||
const safeParseJSON = <T,>(value: string): T | null => {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const withErrorHandling = async <T,>(fn: () => Promise<T>, onError: (e: unknown) => void): Promise<T | undefined> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Inner component keyed by account id so state resets when user switches account
|
||||
const AccountSettingsEditor = ({
|
||||
account,
|
||||
@@ -47,17 +64,21 @@ const AccountSettingsEditor = ({
|
||||
}, [accounts]);
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
try {
|
||||
switchToNewAccountRef.current = true;
|
||||
await createAccount();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
}
|
||||
const result = await withErrorHandling(
|
||||
async () => {
|
||||
switchToNewAccountRef.current = true;
|
||||
await createAccount();
|
||||
},
|
||||
(error) => {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
void result;
|
||||
};
|
||||
|
||||
const _deleteAccount = (accountName: string) => {
|
||||
@@ -73,53 +94,56 @@ const AccountSettingsEditor = ({
|
||||
};
|
||||
|
||||
const saveAccount = async () => {
|
||||
try {
|
||||
const newAccount = JSON.parse(text).account;
|
||||
// force keeping the same id, makes it easier to copy paste
|
||||
await setAccount({ ...newAccount, id: account?.id });
|
||||
const parsed = safeParseJSON<{ account: Record<string, unknown> }>(text);
|
||||
if (!parsed?.account) {
|
||||
alert('Invalid JSON');
|
||||
return;
|
||||
}
|
||||
const newAccount = parsed.account;
|
||||
const result = await withErrorHandling(
|
||||
() => setAccount({ ...newAccount, id: account?.id }),
|
||||
(error) => {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (result !== undefined) {
|
||||
alert(`Saved ${newAccount.name}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAccount = async () => {
|
||||
try {
|
||||
const accountString = await exportAccount();
|
||||
const accountObject = JSON.parse(accountString);
|
||||
const formattedAccountJson = JSON.stringify(accountObject, null, 2);
|
||||
|
||||
// Create a Blob from the JSON string
|
||||
const blob = new Blob([formattedAccountJson], { type: 'application/json' });
|
||||
|
||||
// Create a URL for the Blob
|
||||
const fileUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Create a temporary download link
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = `${account?.name ?? 'account'}.json`;
|
||||
|
||||
// Append the link, trigger the download, then remove the link
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Release the Blob URL
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
const accountString = await withErrorHandling(
|
||||
() => exportAccount(),
|
||||
(error) => {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (accountString === undefined) return;
|
||||
const accountObject = safeParseJSON<Record<string, unknown>>(accountString);
|
||||
if (!accountObject) {
|
||||
alert('Failed to parse account');
|
||||
return;
|
||||
}
|
||||
const formattedAccountJson = JSON.stringify(accountObject, null, 2);
|
||||
const blob = new Blob([formattedAccountJson], { type: 'application/json' });
|
||||
const fileUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = `${account?.name ?? 'account'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
};
|
||||
|
||||
const handleImportAccount = async () => {
|
||||
@@ -128,78 +152,73 @@ const AccountSettingsEditor = ({
|
||||
fileInput.accept = '.json';
|
||||
|
||||
fileInput.onchange = async (event) => {
|
||||
try {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error('No file selected.');
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
if (!files || files.length === 0) {
|
||||
alert('No file selected.');
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const fileContent = e.target!.result;
|
||||
if (typeof fileContent !== 'string') {
|
||||
alert('File content is not a string.');
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const fileContent = e.target!.result;
|
||||
if (typeof fileContent !== 'string') {
|
||||
throw new Error('File content is not a string.');
|
||||
const accountData = safeParseJSON<{
|
||||
account?: { subplebbits?: Record<string, unknown>; subscriptions?: string[]; author?: { address?: string }; name?: string };
|
||||
}>(fileContent);
|
||||
if (!accountData) {
|
||||
alert('Invalid JSON in file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountData.account?.subplebbits) {
|
||||
const subplebbitAddresses = Object.keys(accountData.account.subplebbits);
|
||||
if (!accountData.account.subscriptions) {
|
||||
accountData.account.subscriptions = [];
|
||||
}
|
||||
const uniqueSubscriptions = [...accountData.account.subscriptions];
|
||||
for (const address of subplebbitAddresses) {
|
||||
if (!uniqueSubscriptions.includes(address)) {
|
||||
uniqueSubscriptions.push(address);
|
||||
}
|
||||
}
|
||||
accountData.account.subscriptions = uniqueSubscriptions;
|
||||
}
|
||||
|
||||
const accountData = JSON.parse(fileContent);
|
||||
|
||||
// Add subplebbit addresses to subscriptions if they exist
|
||||
if (accountData.account?.subplebbits) {
|
||||
const subplebbitAddresses = Object.keys(accountData.account.subplebbits);
|
||||
|
||||
if (!accountData.account.subscriptions) {
|
||||
accountData.account.subscriptions = [];
|
||||
}
|
||||
|
||||
const uniqueSubscriptions = [...accountData.account.subscriptions];
|
||||
|
||||
for (const address of subplebbitAddresses) {
|
||||
if (!uniqueSubscriptions.includes(address)) {
|
||||
uniqueSubscriptions.push(address);
|
||||
}
|
||||
}
|
||||
|
||||
accountData.account.subscriptions = uniqueSubscriptions;
|
||||
}
|
||||
|
||||
const modifiedAccountJson = JSON.stringify(accountData);
|
||||
const modifiedAccountJson = JSON.stringify(accountData);
|
||||
const result = await withErrorHandling(
|
||||
async () => {
|
||||
await importAccount(modifiedAccountJson);
|
||||
|
||||
if (accountData.account?.author?.address) {
|
||||
localStorage.setItem('importedAccountAddress', accountData.account.author.address);
|
||||
}
|
||||
|
||||
if (accountData.account?.name) {
|
||||
await setActiveAccount(accountData.account.name);
|
||||
}
|
||||
|
||||
alert(`Imported ${accountData.account?.name}`);
|
||||
|
||||
const currentPath = location.pathname;
|
||||
if (!currentPath.includes('/settings#account-settings')) {
|
||||
navigate(`${currentPath}#account-settings`, { replace: true });
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
},
|
||||
(error) => {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
},
|
||||
);
|
||||
if (result === undefined) return;
|
||||
|
||||
alert(`Imported ${accountData.account?.name}`);
|
||||
const currentPath = location.pathname;
|
||||
if (!currentPath.includes('/settings#account-settings')) {
|
||||
navigate(`${currentPath}#account-settings`, { replace: true });
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
|
||||
@@ -3,6 +3,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAccount, setAccount, useResolvedAuthorAddress } from '@plebbit/plebbit-react-hooks';
|
||||
import styles from './crypto-address-setting.module.css';
|
||||
|
||||
const withErrorHandling = async <T,>(fn: () => Promise<T>, onError: (e: unknown) => void): Promise<T | undefined> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const CryptoAddressSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
const account = useAccount();
|
||||
@@ -75,27 +84,26 @@ const CryptoAddressSetting = () => {
|
||||
alert(t('crypto_address_not_resolved'));
|
||||
return;
|
||||
} else if (resolvedAddress && resolvedAddress === account?.signer?.address) {
|
||||
try {
|
||||
await setAccount({ ...account, author: { ...account?.author, address: cryptoState.cryptoAddress } });
|
||||
const result = await withErrorHandling(
|
||||
() => setAccount({ ...account, author: { ...account?.author, address: cryptoState.cryptoAddress } }),
|
||||
(error) => {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (result !== undefined) {
|
||||
setSavedCryptoAddress(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setSavedCryptoAddress(false);
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => setSavedCryptoAddress(false), 2000);
|
||||
setCryptoState((prevState) => ({
|
||||
...prevState,
|
||||
savedCryptoAddress: true,
|
||||
cryptoAddress: '',
|
||||
checkingCryptoAddress: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
console.log(error);
|
||||
} else {
|
||||
console.error('An unknown error occurred:', error);
|
||||
}
|
||||
}
|
||||
setSavedCryptoAddress(true);
|
||||
setCryptoState((prevState) => ({
|
||||
|
||||
@@ -168,8 +168,8 @@ const CryptoWalletsForm = ({ account }: { account: Account | undefined }) => {
|
||||
<div className={styles.addWallet}>
|
||||
<select onChange={(e) => setSelectedWallet(Number(e.target.value))} value={selectedWallet}>
|
||||
{walletsArray.length === 0 && <option>{t('none')}</option>}
|
||||
{walletsArray.map((_, index) => (
|
||||
<option key={index} value={index}>
|
||||
{walletsArray.map((wallet, index) => (
|
||||
<option key={wallet.address || (wallet.chainTicker ? `${wallet.chainTicker}-${index}` : `wallet-${index}`)} value={index}>
|
||||
{t('wallet')} #{index + 1}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -10,52 +10,54 @@ import Version from '../../version';
|
||||
const commitRef = process.env.VITE_COMMIT_REF;
|
||||
const isElectron = window.electronApi?.isElectron === true;
|
||||
|
||||
const fetchLatestVersionInfo = async (t: (key: string, opts?: Record<string, unknown>) => string): Promise<void> => {
|
||||
try {
|
||||
const packageRes = await fetch('https://raw.githubusercontent.com/bitsocialhq/5chan/master/package.json', { cache: 'no-cache' });
|
||||
const packageData = await packageRes.json();
|
||||
let updateAvailable = false;
|
||||
|
||||
if (packageJson.version !== packageData.version) {
|
||||
const newVersionText = t('new_stable_version', { newVersion: packageData.version, oldVersion: packageJson.version });
|
||||
const updateActionText = isElectron
|
||||
? t('download_latest_desktop', { link: 'https://github.com/bitsocialhq/5chan/releases/latest', interpolation: { escapeValue: false } })
|
||||
: t('refresh_to_update');
|
||||
alert(newVersionText + ' ' + updateActionText);
|
||||
updateAvailable = true;
|
||||
}
|
||||
|
||||
if (commitRef && commitRef.length > 0) {
|
||||
const commitRes = await fetch('https://api.github.com/repos/bitsocialhq/5chan/commits?per_page=1&sha=development', { cache: 'no-cache' });
|
||||
const commitData = await commitRes.json();
|
||||
|
||||
const latestCommitHash = commitData[0].sha;
|
||||
|
||||
if (latestCommitHash.trim() !== commitRef.trim()) {
|
||||
const newVersionText = t('new_development_version', { newCommit: latestCommitHash.slice(0, 7), oldCommit: commitRef.slice(0, 7) }) + ' ' + t('refresh_to_update');
|
||||
alert(newVersionText);
|
||||
updateAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateAvailable) {
|
||||
alert(
|
||||
commitRef
|
||||
? `${t('latest_development_version', { commit: commitRef.slice(0, 7), link: 'https://5chan.app/#/', interpolation: { escapeValue: false } })}`
|
||||
: `${t('latest_stable_version', { version: packageJson.version })}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to fetch latest version info: ' + error);
|
||||
}
|
||||
};
|
||||
|
||||
const CheckForUpdates = () => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const packageRes = await fetch('https://raw.githubusercontent.com/bitsocialhq/5chan/master/package.json', { cache: 'no-cache' });
|
||||
const packageData = await packageRes.json();
|
||||
let updateAvailable = false;
|
||||
|
||||
if (packageJson.version !== packageData.version) {
|
||||
const newVersionText = t('new_stable_version', { newVersion: packageData.version, oldVersion: packageJson.version });
|
||||
const updateActionText = isElectron
|
||||
? t('download_latest_desktop', { link: 'https://github.com/bitsocialhq/5chan/releases/latest', interpolation: { escapeValue: false } })
|
||||
: t('refresh_to_update');
|
||||
alert(newVersionText + ' ' + updateActionText);
|
||||
updateAvailable = true;
|
||||
}
|
||||
|
||||
if (commitRef && commitRef.length > 0) {
|
||||
const commitRes = await fetch('https://api.github.com/repos/bitsocialhq/5chan/commits?per_page=1&sha=development', { cache: 'no-cache' });
|
||||
const commitData = await commitRes.json();
|
||||
|
||||
const latestCommitHash = commitData[0].sha;
|
||||
|
||||
if (latestCommitHash.trim() !== commitRef.trim()) {
|
||||
const newVersionText =
|
||||
t('new_development_version', { newCommit: latestCommitHash.slice(0, 7), oldCommit: commitRef.slice(0, 7) }) + ' ' + t('refresh_to_update');
|
||||
alert(newVersionText);
|
||||
updateAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateAvailable) {
|
||||
alert(
|
||||
commitRef
|
||||
? `${t('latest_development_version', { commit: commitRef.slice(0, 7), link: 'https://5chan.app/#/', interpolation: { escapeValue: false } })}`
|
||||
: `${t('latest_stable_version', { version: packageJson.version })}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to fetch latest version info: ' + error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(true);
|
||||
await fetchLatestVersionInfo(t);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,13 +34,15 @@ const SettingsModal = () => {
|
||||
};
|
||||
}, [closeModal]);
|
||||
|
||||
const [showInterfaceSettings, setShowInterfaceSettings] = useState(false);
|
||||
const [showMediaHostingSettings, setShowMediaHostingSettings] = useState(false);
|
||||
const [showAccountSettings, setShowAccountSettings] = useState(false);
|
||||
const [showSubscriptionsSettings, setShowSubscriptionsSettings] = useState(false);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const expandAccount = hash === 'account-settings' || hash === 'crypto-address-settings' || hash === 'crypto-wallet-settings';
|
||||
const showInterfaceSettings = expandAll || hash === 'interface-settings';
|
||||
const showMediaHostingSettings = expandAll || hash === 'media-hosting-settings';
|
||||
const showAccountSettings = expandAll || expandAccount;
|
||||
const showSubscriptionsSettings = expandAll || hash === 'subscriptions-settings';
|
||||
const showAdvancedSettings = expandAll || hash === 'advanced-settings';
|
||||
|
||||
const getExpandedCount = () => {
|
||||
return (
|
||||
Number(showInterfaceSettings) + Number(showMediaHostingSettings) + Number(showAccountSettings) + Number(showSubscriptionsSettings) + Number(showAdvancedSettings)
|
||||
@@ -56,13 +58,10 @@ const SettingsModal = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCategoryClick = (categoryId: string, isShowing: boolean, setShowing: (value: boolean) => void) => {
|
||||
const handleCategoryClick = (categoryId: string, isShowing: boolean) => {
|
||||
const newState = !isShowing;
|
||||
setShowing(newState);
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const baseSettingsPath = currentPath.split('#')[0];
|
||||
|
||||
const currentExpandedCount = getExpandedCount();
|
||||
|
||||
if (newState) {
|
||||
@@ -83,57 +82,50 @@ const SettingsModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hash) {
|
||||
const expandAccount = hash === 'account-settings' || hash === 'crypto-address-settings' || hash === 'crypto-wallet-settings';
|
||||
setShowInterfaceSettings(hash === 'interface-settings');
|
||||
setShowMediaHostingSettings(hash === 'media-hosting-settings');
|
||||
setShowAccountSettings(expandAccount);
|
||||
setShowSubscriptionsSettings(hash === 'subscriptions-settings');
|
||||
setShowAdvancedSettings(hash === 'advanced-settings');
|
||||
}
|
||||
}, [hash]);
|
||||
|
||||
const handleExpandAll = () => {
|
||||
const newExpandState = !expandAll;
|
||||
setExpandAll(newExpandState);
|
||||
setShowInterfaceSettings(newExpandState);
|
||||
setShowMediaHostingSettings(newExpandState);
|
||||
setShowAccountSettings(newExpandState);
|
||||
setShowSubscriptionsSettings(newExpandState);
|
||||
setShowAdvancedSettings(newExpandState);
|
||||
|
||||
setExpandAll((prev) => !prev);
|
||||
const baseSettingsPath = location.pathname.split('#')[0];
|
||||
navigate(baseSettingsPath, { replace: true });
|
||||
};
|
||||
|
||||
const handleKeyDown = (handler: () => void) => (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.overlay} onClick={closeModal} />
|
||||
<div className={styles.overlay} role='button' tabIndex={0} onClick={closeModal} onKeyDown={handleKeyDown(closeModal)} />
|
||||
<div className={styles.settingsModal}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{t('settings')}</span>
|
||||
<span className={styles.closeButton} title='close' onClick={closeModal} />
|
||||
<span className={styles.closeButton} role='button' tabIndex={0} title='close' onClick={closeModal} onKeyDown={handleKeyDown(closeModal)} />
|
||||
</div>
|
||||
<div className={styles.expandAllSettings}>
|
||||
[<span onClick={handleExpandAll}>{expandAll ? t('collapse_all_settings') : t('expand_all_settings')}</span>]
|
||||
[
|
||||
<span role='button' tabIndex={0} onClick={handleExpandAll} onKeyDown={handleKeyDown(handleExpandAll)}>
|
||||
{expandAll ? t('collapse_all_settings') : t('expand_all_settings')}
|
||||
</span>
|
||||
]
|
||||
</div>
|
||||
<div id='interface-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('interface-settings', showInterfaceSettings, setShowInterfaceSettings)}>
|
||||
<label onClick={() => handleCategoryClick('interface-settings', showInterfaceSettings)}>
|
||||
<span className={showInterfaceSettings ? styles.hideButton : styles.showButton} />
|
||||
{t('interface')}
|
||||
</label>
|
||||
</div>
|
||||
{showInterfaceSettings && <InterfaceSettings />}
|
||||
<div id='media-hosting-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('media-hosting-settings', showMediaHostingSettings, setShowMediaHostingSettings)}>
|
||||
<label onClick={() => handleCategoryClick('media-hosting-settings', showMediaHostingSettings)}>
|
||||
<span className={showMediaHostingSettings ? styles.hideButton : styles.showButton} />
|
||||
{t('media_hosting')}
|
||||
</label>
|
||||
</div>
|
||||
{showMediaHostingSettings && <MediaHostingSettings />}
|
||||
<div id='account-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('account-settings', showAccountSettings, setShowAccountSettings)}>
|
||||
<label onClick={() => handleCategoryClick('account-settings', showAccountSettings)}>
|
||||
<span className={showAccountSettings ? styles.hideButton : styles.showButton} />
|
||||
{t('bitsocial_account')}
|
||||
</label>
|
||||
@@ -148,14 +140,14 @@ const SettingsModal = () => {
|
||||
</>
|
||||
)}
|
||||
<div id='subscriptions-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('subscriptions-settings', showSubscriptionsSettings, setShowSubscriptionsSettings)}>
|
||||
<label onClick={() => handleCategoryClick('subscriptions-settings', showSubscriptionsSettings)}>
|
||||
<span className={showSubscriptionsSettings ? styles.hideButton : styles.showButton} />
|
||||
{t('board_subscriptions')}
|
||||
</label>
|
||||
</div>
|
||||
{showSubscriptionsSettings && <SubscriptionsSetting />}
|
||||
<div id='advanced-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('advanced-settings', showAdvancedSettings, setShowAdvancedSettings)}>
|
||||
<label onClick={() => handleCategoryClick('advanced-settings', showAdvancedSettings)}>
|
||||
<span className={showAdvancedSettings ? styles.hideButton : styles.showButton} />
|
||||
{t('advanced_settings')}
|
||||
</label>
|
||||
|
||||
@@ -22,7 +22,18 @@ const SubscriptionButton = ({ address }: { address: string }) => {
|
||||
return (
|
||||
<span className={styles.subscriptionButton}>
|
||||
[
|
||||
<span className={styles.button} onClick={handleClick}>
|
||||
<span
|
||||
className={styles.button}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{recentlyUnsubscribed || !subscribed ? t('subscribe') : t('unsubscribe')}
|
||||
</span>
|
||||
]
|
||||
@@ -48,7 +59,18 @@ const SubscriptionsSetting = () => {
|
||||
{subscriptions?.length > 1 && (
|
||||
<div className={styles.unsubscribeAll}>
|
||||
[
|
||||
<span className={styles.button} onClick={unsubscribeAll}>
|
||||
<span
|
||||
className={styles.button}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
unsubscribeAll();
|
||||
}
|
||||
}}
|
||||
onClick={unsubscribeAll}
|
||||
>
|
||||
{t('unsubscribe_all')}
|
||||
</span>
|
||||
]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import { Challenge } from '@plebbit/plebbit-react-hooks';
|
||||
|
||||
let nextChallengeId = 0;
|
||||
|
||||
interface State {
|
||||
challenges: Challenge[];
|
||||
challenges: Array<{ challenge: Challenge; id: number }>;
|
||||
addChallenge: (challenge: Challenge) => void;
|
||||
removeChallenge: () => void;
|
||||
}
|
||||
@@ -10,7 +12,9 @@ interface State {
|
||||
const useChallengesStore = create<State>((set) => ({
|
||||
challenges: [],
|
||||
addChallenge: (challenge: Challenge) => {
|
||||
set((state) => ({ challenges: [...state.challenges, challenge] }));
|
||||
set((state) => ({
|
||||
challenges: [...state.challenges, { challenge, id: nextChallengeId++ }],
|
||||
}));
|
||||
},
|
||||
removeChallenge: () => {
|
||||
set((state) => {
|
||||
|
||||
@@ -41,7 +41,18 @@ const BoardsFilterModal = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={buttonRef} onClick={() => !showFilterModal && setShowFilterModal(true)}>
|
||||
<span
|
||||
ref={buttonRef}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!showFilterModal) setShowFilterModal(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => !showFilterModal && setShowFilterModal(true)}
|
||||
>
|
||||
{t('filter')} ▼
|
||||
</span>
|
||||
{showFilterModal && (
|
||||
@@ -49,6 +60,15 @@ const BoardsFilterModal = () => {
|
||||
{/* Always shown: Use Catalog */}
|
||||
<div
|
||||
className={`${styles.option} ${useCatalogLinks && styles.selected}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setUseCatalogLinks(!useCatalogLinks);
|
||||
setShowFilterModal(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setUseCatalogLinks(!useCatalogLinks);
|
||||
setShowFilterModal(false);
|
||||
@@ -63,6 +83,15 @@ const BoardsFilterModal = () => {
|
||||
<div className={styles.separator} />
|
||||
<div
|
||||
className={`${styles.option} ${boardFilter === 'all' && styles.selected}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setBoardFilter('all');
|
||||
setShowFilterModal(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setBoardFilter('all');
|
||||
setShowFilterModal(false);
|
||||
@@ -72,6 +101,15 @@ const BoardsFilterModal = () => {
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.option} ${boardFilter === 'nsfw' && styles.selected}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setBoardFilter('nsfw');
|
||||
setShowFilterModal(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setBoardFilter('nsfw');
|
||||
setShowFilterModal(false);
|
||||
@@ -81,6 +119,15 @@ const BoardsFilterModal = () => {
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.option} ${boardFilter === 'worksafe' && styles.selected}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setBoardFilter('worksafe');
|
||||
setShowFilterModal(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setBoardFilter('worksafe');
|
||||
setShowFilterModal(false);
|
||||
|
||||
@@ -26,6 +26,34 @@ const NSFWBadge = () => {
|
||||
);
|
||||
};
|
||||
|
||||
interface BoardLinkProps {
|
||||
boardName: string;
|
||||
address: string | null;
|
||||
getBoardLink: (address: string) => string;
|
||||
onLinkClick: (e: React.MouseEvent<HTMLAnchorElement>, address: string) => void;
|
||||
onPlaceholderClick: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
const BoardLink = ({ boardName, address, getBoardLink, onLinkClick, onPlaceholderClick }: BoardLinkProps) => {
|
||||
if (address) {
|
||||
return (
|
||||
<li>
|
||||
<Link to={getBoardLink(address)} onClick={(e) => onLinkClick(e, address)}>
|
||||
{boardName}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link to='#' onClick={onPlaceholderClick} className={styles.placeholder}>
|
||||
{boardName}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -64,27 +92,10 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const renderBoardLink = (boardName: string) => {
|
||||
const address = boardAddressesByName[boardName];
|
||||
const key = address ?? boardName;
|
||||
|
||||
if (address) {
|
||||
return (
|
||||
<li key={key}>
|
||||
<Link to={getBoardLink(address)} onClick={(e) => handleLinkClick(e, address)}>
|
||||
{boardName}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={key}>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
{boardName}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
const boardLinkProps = {
|
||||
getBoardLink,
|
||||
onLinkClick: handleLinkClick,
|
||||
onPlaceholderClick: handlePlaceholderClick,
|
||||
};
|
||||
|
||||
const errorMessage = error?.message;
|
||||
@@ -122,20 +133,62 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
<ul>
|
||||
{(showAll || showWorksafeOnly) && (
|
||||
<>
|
||||
{renderBoardLink('Anime & Manga')}
|
||||
{renderBoardLink('Anime/Cute')}
|
||||
{renderBoardLink('Anime/Wallpapers')}
|
||||
{renderBoardLink('Mecha')}
|
||||
{renderBoardLink('Cosplay & EGL')}
|
||||
{renderBoardLink('Cute/Male')}
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Anime & Manga'] ?? 'Anime & Manga'}
|
||||
boardName='Anime & Manga'
|
||||
address={boardAddressesByName['Anime & Manga'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Anime/Cute'] ?? 'Anime/Cute'}
|
||||
boardName='Anime/Cute'
|
||||
address={boardAddressesByName['Anime/Cute'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Anime/Wallpapers'] ?? 'Anime/Wallpapers'}
|
||||
boardName='Anime/Wallpapers'
|
||||
address={boardAddressesByName['Anime/Wallpapers'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Mecha'] ?? 'Mecha'} boardName='Mecha' address={boardAddressesByName['Mecha'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Cosplay & EGL'] ?? 'Cosplay & EGL'}
|
||||
boardName='Cosplay & EGL'
|
||||
address={boardAddressesByName['Cosplay & EGL'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Cute/Male'] ?? 'Cute/Male'}
|
||||
boardName='Cute/Male'
|
||||
address={boardAddressesByName['Cute/Male'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(showAll || showNsfwOnly) && renderBoardLink('Flash')}
|
||||
{(showAll || showNsfwOnly) && (
|
||||
<BoardLink key={boardAddressesByName['Flash'] ?? 'Flash'} boardName='Flash' address={boardAddressesByName['Flash'] ?? null} {...boardLinkProps} />
|
||||
)}
|
||||
{(showAll || showWorksafeOnly) && (
|
||||
<>
|
||||
{renderBoardLink('Transportation')}
|
||||
{renderBoardLink('Otaku Culture')}
|
||||
{renderBoardLink('Virtual YouTubers')}
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Transportation'] ?? 'Transportation'}
|
||||
boardName='Transportation'
|
||||
address={boardAddressesByName['Transportation'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Otaku Culture'] ?? 'Otaku Culture'}
|
||||
boardName='Otaku Culture'
|
||||
address={boardAddressesByName['Otaku Culture'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Virtual YouTubers'] ?? 'Virtual YouTubers'}
|
||||
boardName='Virtual YouTubers'
|
||||
address={boardAddressesByName['Virtual YouTubers'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
@@ -147,14 +200,54 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
<>
|
||||
<h3>Video Games</h3>
|
||||
<ul>
|
||||
{renderBoardLink('Video Games')}
|
||||
{renderBoardLink('Video Game Generals')}
|
||||
{renderBoardLink('Video Games/Multiplayer')}
|
||||
{renderBoardLink('Video Games/Mobile')}
|
||||
{renderBoardLink('Pokémon')}
|
||||
{renderBoardLink('Retro Games')}
|
||||
{renderBoardLink('Video Games/RPG')}
|
||||
{renderBoardLink('Video Games/Strategy')}
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Video Games'] ?? 'Video Games'}
|
||||
boardName='Video Games'
|
||||
address={boardAddressesByName['Video Games'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Video Game Generals'] ?? 'Video Game Generals'}
|
||||
boardName='Video Game Generals'
|
||||
address={boardAddressesByName['Video Game Generals'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Video Games/Multiplayer'] ?? 'Video Games/Multiplayer'}
|
||||
boardName='Video Games/Multiplayer'
|
||||
address={boardAddressesByName['Video Games/Multiplayer'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Video Games/Mobile'] ?? 'Video Games/Mobile'}
|
||||
boardName='Video Games/Mobile'
|
||||
address={boardAddressesByName['Video Games/Mobile'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Pokémon'] ?? 'Pokémon'}
|
||||
boardName='Pokémon'
|
||||
address={boardAddressesByName['Pokémon'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Retro Games'] ?? 'Retro Games'}
|
||||
boardName='Retro Games'
|
||||
address={boardAddressesByName['Retro Games'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Video Games/RPG'] ?? 'Video Games/RPG'}
|
||||
boardName='Video Games/RPG'
|
||||
address={boardAddressesByName['Video Games/RPG'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Video Games/Strategy'] ?? 'Video Games/Strategy'}
|
||||
boardName='Video Games/Strategy'
|
||||
address={boardAddressesByName['Video Games/Strategy'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
@@ -166,21 +259,76 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
<div className={styles.boardsColumn}>
|
||||
<h3>Interests</h3>
|
||||
<ul>
|
||||
{renderBoardLink('Comics & Cartoons')}
|
||||
{renderBoardLink('Technology')}
|
||||
{renderBoardLink('Television & Film')}
|
||||
{renderBoardLink('Weapons')}
|
||||
{renderBoardLink('Auto')}
|
||||
{renderBoardLink('Animals & Nature')}
|
||||
{renderBoardLink('Traditional Games')}
|
||||
{renderBoardLink('Sports')}
|
||||
{renderBoardLink('Extreme Sports')}
|
||||
{renderBoardLink('Professional Wrestling')}
|
||||
{renderBoardLink('Science & Math')}
|
||||
{renderBoardLink('History & Humanities')}
|
||||
{renderBoardLink('International')}
|
||||
{renderBoardLink('Outdoors')}
|
||||
{renderBoardLink('Toys')}
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Comics & Cartoons'] ?? 'Comics & Cartoons'}
|
||||
boardName='Comics & Cartoons'
|
||||
address={boardAddressesByName['Comics & Cartoons'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Technology'] ?? 'Technology'}
|
||||
boardName='Technology'
|
||||
address={boardAddressesByName['Technology'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Television & Film'] ?? 'Television & Film'}
|
||||
boardName='Television & Film'
|
||||
address={boardAddressesByName['Television & Film'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Weapons'] ?? 'Weapons'} boardName='Weapons' address={boardAddressesByName['Weapons'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink key={boardAddressesByName['Auto'] ?? 'Auto'} boardName='Auto' address={boardAddressesByName['Auto'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Animals & Nature'] ?? 'Animals & Nature'}
|
||||
boardName='Animals & Nature'
|
||||
address={boardAddressesByName['Animals & Nature'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Traditional Games'] ?? 'Traditional Games'}
|
||||
boardName='Traditional Games'
|
||||
address={boardAddressesByName['Traditional Games'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Sports'] ?? 'Sports'} boardName='Sports' address={boardAddressesByName['Sports'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Extreme Sports'] ?? 'Extreme Sports'}
|
||||
boardName='Extreme Sports'
|
||||
address={boardAddressesByName['Extreme Sports'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Professional Wrestling'] ?? 'Professional Wrestling'}
|
||||
boardName='Professional Wrestling'
|
||||
address={boardAddressesByName['Professional Wrestling'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Science & Math'] ?? 'Science & Math'}
|
||||
boardName='Science & Math'
|
||||
address={boardAddressesByName['Science & Math'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['History & Humanities'] ?? 'History & Humanities'}
|
||||
boardName='History & Humanities'
|
||||
address={boardAddressesByName['History & Humanities'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['International'] ?? 'International'}
|
||||
boardName='International'
|
||||
address={boardAddressesByName['International'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Outdoors'] ?? 'Outdoors'}
|
||||
boardName='Outdoors'
|
||||
address={boardAddressesByName['Outdoors'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Toys'] ?? 'Toys'} boardName='Toys' address={boardAddressesByName['Toys'] ?? null} {...boardLinkProps} />
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -190,26 +338,82 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
<div className={styles.boardsColumn}>
|
||||
<h3>Creative</h3>
|
||||
<ul>
|
||||
{(showAll || showNsfwOnly) && renderBoardLink('Oekaki')}
|
||||
{(showAll || showNsfwOnly) && (
|
||||
<BoardLink key={boardAddressesByName['Oekaki'] ?? 'Oekaki'} boardName='Oekaki' address={boardAddressesByName['Oekaki'] ?? null} {...boardLinkProps} />
|
||||
)}
|
||||
{(showAll || showWorksafeOnly) && (
|
||||
<>
|
||||
{renderBoardLink('Papercraft & Origami')}
|
||||
{renderBoardLink('Photography')}
|
||||
{renderBoardLink('Food & Cooking')}
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Papercraft & Origami'] ?? 'Papercraft & Origami'}
|
||||
boardName='Papercraft & Origami'
|
||||
address={boardAddressesByName['Papercraft & Origami'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Photography'] ?? 'Photography'}
|
||||
boardName='Photography'
|
||||
address={boardAddressesByName['Photography'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Food & Cooking'] ?? 'Food & Cooking'}
|
||||
boardName='Food & Cooking'
|
||||
address={boardAddressesByName['Food & Cooking'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(showAll || showNsfwOnly) && renderBoardLink('Artwork/Critique')}
|
||||
{(showAll || showNsfwOnly) && renderBoardLink('Wallpapers/General')}
|
||||
{(showAll || showNsfwOnly) && (
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Artwork/Critique'] ?? 'Artwork/Critique'}
|
||||
boardName='Artwork/Critique'
|
||||
address={boardAddressesByName['Artwork/Critique'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
)}
|
||||
{(showAll || showNsfwOnly) && (
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Wallpapers/General'] ?? 'Wallpapers/General'}
|
||||
boardName='Wallpapers/General'
|
||||
address={boardAddressesByName['Wallpapers/General'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
)}
|
||||
{(showAll || showWorksafeOnly) && (
|
||||
<>
|
||||
{renderBoardLink('Literature')}
|
||||
{renderBoardLink('Music')}
|
||||
{renderBoardLink('Fashion')}
|
||||
{renderBoardLink('3DCG')}
|
||||
{renderBoardLink('Graphic Design')}
|
||||
{renderBoardLink('Do It Yourself')}
|
||||
{renderBoardLink('Worksafe GIF')}
|
||||
{renderBoardLink('Quests')}
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Literature'] ?? 'Literature'}
|
||||
boardName='Literature'
|
||||
address={boardAddressesByName['Literature'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Music'] ?? 'Music'} boardName='Music' address={boardAddressesByName['Music'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Fashion'] ?? 'Fashion'}
|
||||
boardName='Fashion'
|
||||
address={boardAddressesByName['Fashion'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['3DCG'] ?? '3DCG'} boardName='3DCG' address={boardAddressesByName['3DCG'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Graphic Design'] ?? 'Graphic Design'}
|
||||
boardName='Graphic Design'
|
||||
address={boardAddressesByName['Graphic Design'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Do It Yourself'] ?? 'Do It Yourself'}
|
||||
boardName='Do It Yourself'
|
||||
address={boardAddressesByName['Do It Yourself'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Worksafe GIF'] ?? 'Worksafe GIF'}
|
||||
boardName='Worksafe GIF'
|
||||
address={boardAddressesByName['Worksafe GIF'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Quests'] ?? 'Quests'} boardName='Quests' address={boardAddressesByName['Quests'] ?? null} {...boardLinkProps} />
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
@@ -224,16 +428,46 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
<>
|
||||
<h3>Other</h3>
|
||||
<ul>
|
||||
{renderBoardLink('Business & Finance')}
|
||||
{renderBoardLink('Travel')}
|
||||
{renderBoardLink('Fitness')}
|
||||
{renderBoardLink('Paranormal')}
|
||||
{renderBoardLink('Advice')}
|
||||
{renderBoardLink('LGBT')}
|
||||
{renderBoardLink('Pony')}
|
||||
{renderBoardLink('Current News')}
|
||||
{renderBoardLink('Worksafe Requests')}
|
||||
{renderBoardLink('Very Important Posts')}
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Business & Finance'] ?? 'Business & Finance'}
|
||||
boardName='Business & Finance'
|
||||
address={boardAddressesByName['Business & Finance'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Travel'] ?? 'Travel'} boardName='Travel' address={boardAddressesByName['Travel'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Fitness'] ?? 'Fitness'}
|
||||
boardName='Fitness'
|
||||
address={boardAddressesByName['Fitness'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Paranormal'] ?? 'Paranormal'}
|
||||
boardName='Paranormal'
|
||||
address={boardAddressesByName['Paranormal'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Advice'] ?? 'Advice'} boardName='Advice' address={boardAddressesByName['Advice'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink key={boardAddressesByName['LGBT'] ?? 'LGBT'} boardName='LGBT' address={boardAddressesByName['LGBT'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink key={boardAddressesByName['Pony'] ?? 'Pony'} boardName='Pony' address={boardAddressesByName['Pony'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Current News'] ?? 'Current News'}
|
||||
boardName='Current News'
|
||||
address={boardAddressesByName['Current News'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Worksafe Requests'] ?? 'Worksafe Requests'}
|
||||
boardName='Worksafe Requests'
|
||||
address={boardAddressesByName['Worksafe Requests'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Very Important Posts'] ?? 'Very Important Posts'}
|
||||
boardName='Very Important Posts'
|
||||
address={boardAddressesByName['Very Important Posts'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
@@ -244,12 +478,37 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
<h3>Misc.</h3>
|
||||
<NSFWBadge />
|
||||
<ul>
|
||||
{renderBoardLink('Random')}
|
||||
{renderBoardLink('ROBOT9001')}
|
||||
{renderBoardLink('Politically Incorrect')}
|
||||
{renderBoardLink('International/Random')}
|
||||
{renderBoardLink('Cams & Meetups')}
|
||||
{renderBoardLink('Shit 5chan Says')}
|
||||
<BoardLink key={boardAddressesByName['Random'] ?? 'Random'} boardName='Random' address={boardAddressesByName['Random'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['ROBOT9001'] ?? 'ROBOT9001'}
|
||||
boardName='ROBOT9001'
|
||||
address={boardAddressesByName['ROBOT9001'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Politically Incorrect'] ?? 'Politically Incorrect'}
|
||||
boardName='Politically Incorrect'
|
||||
address={boardAddressesByName['Politically Incorrect'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['International/Random'] ?? 'International/Random'}
|
||||
boardName='International/Random'
|
||||
address={boardAddressesByName['International/Random'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Cams & Meetups'] ?? 'Cams & Meetups'}
|
||||
boardName='Cams & Meetups'
|
||||
address={boardAddressesByName['Cams & Meetups'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Shit 5chan Says'] ?? 'Shit 5chan Says'}
|
||||
boardName='Shit 5chan Says'
|
||||
address={boardAddressesByName['Shit 5chan Says'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
@@ -262,19 +521,64 @@ const BoardsList = ({ multisub }: { multisub: DirectoryCommunity[] }) => {
|
||||
<h3>Adult</h3>
|
||||
<NSFWBadge />
|
||||
<ul>
|
||||
{renderBoardLink('Sexy Beautiful Women')}
|
||||
{renderBoardLink('Hardcore')}
|
||||
{renderBoardLink('Handsome Men')}
|
||||
{renderBoardLink('Hentai')}
|
||||
{renderBoardLink('Ecchi')}
|
||||
{renderBoardLink('Yuri')}
|
||||
{renderBoardLink('Hentai/Alternative')}
|
||||
{renderBoardLink('Yaoi')}
|
||||
{renderBoardLink('Torrents')}
|
||||
{renderBoardLink('High Resolution')}
|
||||
{renderBoardLink('Adult GIF')}
|
||||
{renderBoardLink('Adult Cartoons')}
|
||||
{renderBoardLink('Adult Requests')}
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Sexy Beautiful Women'] ?? 'Sexy Beautiful Women'}
|
||||
boardName='Sexy Beautiful Women'
|
||||
address={boardAddressesByName['Sexy Beautiful Women'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Hardcore'] ?? 'Hardcore'}
|
||||
boardName='Hardcore'
|
||||
address={boardAddressesByName['Hardcore'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Handsome Men'] ?? 'Handsome Men'}
|
||||
boardName='Handsome Men'
|
||||
address={boardAddressesByName['Handsome Men'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Hentai'] ?? 'Hentai'} boardName='Hentai' address={boardAddressesByName['Hentai'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink key={boardAddressesByName['Ecchi'] ?? 'Ecchi'} boardName='Ecchi' address={boardAddressesByName['Ecchi'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink key={boardAddressesByName['Yuri'] ?? 'Yuri'} boardName='Yuri' address={boardAddressesByName['Yuri'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Hentai/Alternative'] ?? 'Hentai/Alternative'}
|
||||
boardName='Hentai/Alternative'
|
||||
address={boardAddressesByName['Hentai/Alternative'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink key={boardAddressesByName['Yaoi'] ?? 'Yaoi'} boardName='Yaoi' address={boardAddressesByName['Yaoi'] ?? null} {...boardLinkProps} />
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Torrents'] ?? 'Torrents'}
|
||||
boardName='Torrents'
|
||||
address={boardAddressesByName['Torrents'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['High Resolution'] ?? 'High Resolution'}
|
||||
boardName='High Resolution'
|
||||
address={boardAddressesByName['High Resolution'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Adult GIF'] ?? 'Adult GIF'}
|
||||
boardName='Adult GIF'
|
||||
address={boardAddressesByName['Adult GIF'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Adult Cartoons'] ?? 'Adult Cartoons'}
|
||||
boardName='Adult Cartoons'
|
||||
address={boardAddressesByName['Adult Cartoons'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
<BoardLink
|
||||
key={boardAddressesByName['Adult Requests'] ?? 'Adult Requests'}
|
||||
boardName='Adult Requests'
|
||||
address={boardAddressesByName['Adult Requests'] ?? null}
|
||||
{...boardLinkProps}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,13 +29,36 @@ const BoxModal = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={buttonRef} onClick={() => !showFilterModal && setShowFilterModal(true)}>
|
||||
<span
|
||||
ref={buttonRef}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!showFilterModal) setShowFilterModal(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => !showFilterModal && setShowFilterModal(true)}
|
||||
>
|
||||
{t('options')} ▼
|
||||
</span>
|
||||
{showFilterModal && (
|
||||
<div ref={modalRef} className={styles.filterModal}>
|
||||
<div
|
||||
className={`${styles.option} ${showWorksafeContentOnly && styles.selected}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (showNsfwContentOnly) {
|
||||
setShowNsfwContentOnly(false);
|
||||
}
|
||||
setShowWorksafeContentOnly(!showWorksafeContentOnly);
|
||||
setShowFilterModal(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (showNsfwContentOnly) {
|
||||
setShowNsfwContentOnly(false);
|
||||
@@ -48,6 +71,18 @@ const BoxModal = () => {
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.option} ${showNsfwContentOnly && styles.selected}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (showWorksafeContentOnly) {
|
||||
setShowWorksafeContentOnly(false);
|
||||
}
|
||||
setShowNsfwContentOnly(!showNsfwContentOnly);
|
||||
setShowFilterModal(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (showWorksafeContentOnly) {
|
||||
setShowWorksafeContentOnly(false);
|
||||
@@ -60,6 +95,16 @@ const BoxModal = () => {
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.option} ${!showWorksafeContentOnly && !showNsfwContentOnly && styles.selected}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowWorksafeContentOnly(false);
|
||||
setShowNsfwContentOnly(false);
|
||||
setShowFilterModal(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowWorksafeContentOnly(false);
|
||||
setShowNsfwContentOnly(false);
|
||||
|
||||
@@ -66,6 +66,72 @@ interface ModQueueActionState {
|
||||
handleReject: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ModQueueActionsProps {
|
||||
status: 'approved' | 'rejected' | 'failed' | null;
|
||||
errorMessage?: string;
|
||||
isPublishing: boolean;
|
||||
handleApprove: () => Promise<void>;
|
||||
handleReject: () => Promise<void>;
|
||||
variant: 'row' | 'card';
|
||||
}
|
||||
|
||||
const ModQueueActions = ({ status, errorMessage, isPublishing, handleApprove, handleReject, variant }: ModQueueActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (status === 'approved') {
|
||||
const content = <span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>;
|
||||
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
const content = <span className={`${styles.button} ${styles.reject}`}>{t('rejected')}</span>;
|
||||
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
||||
}
|
||||
if (status === 'failed') {
|
||||
const content = (
|
||||
<span className={`${styles.button} ${styles.reject}`}>
|
||||
{t('failed')}
|
||||
{errorMessage ? `: ${errorMessage}` : ''}
|
||||
</span>
|
||||
);
|
||||
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
||||
}
|
||||
if (isPublishing) {
|
||||
const content = <LoadingEllipsis string={t('publishing')} />;
|
||||
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
||||
}
|
||||
|
||||
const buttons =
|
||||
variant === 'row' ? (
|
||||
<div className={styles.actionButtons}>
|
||||
<span className={styles.buttonWrapper}>
|
||||
[
|
||||
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.buttonWrapper}>
|
||||
[
|
||||
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.cardActions}>
|
||||
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
||||
const { t } = useTranslation();
|
||||
const { cid, subplebbitAddress, approved, removed } = comment || {};
|
||||
@@ -202,47 +268,6 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => {
|
||||
const threadTargetCid = threadCid || cid;
|
||||
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
||||
|
||||
// Render the status or action buttons
|
||||
const renderActions = () => {
|
||||
// Check existing moderation state first (from API/previous sessions)
|
||||
if (status === 'approved') {
|
||||
return <span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>;
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return <span className={`${styles.button} ${styles.reject}`}>{t('rejected')}</span>;
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<span className={`${styles.button} ${styles.reject}`}>
|
||||
{t('failed')}
|
||||
{errorMessage ? `: ${errorMessage}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isPublishing) {
|
||||
return <LoadingEllipsis string={t('publishing')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.actionButtons}>
|
||||
<span className={styles.buttonWrapper}>
|
||||
[
|
||||
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.buttonWrapper}>
|
||||
[
|
||||
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.row} ${isOdd ? styles.rowOdd : ''}`}>
|
||||
<div className={styles.number}>{number ?? 'N/A'}</div>
|
||||
@@ -266,19 +291,32 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => {
|
||||
) : // On desktop, show full date with tooltip
|
||||
isAwaitingApproval && isOverThreshold ? (
|
||||
<>
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
|
||||
<Tooltip content={getFormattedTimeAgo(timestamp)}>
|
||||
<span>{getFormattedDate(timestamp)}</span>
|
||||
</Tooltip>
|
||||
<span className={styles.alertWrapper}>
|
||||
{' '}
|
||||
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
|
||||
<Tooltip content={getFormattedTimeAgo(timestamp)}>
|
||||
<span>{getFormattedDate(timestamp)}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.type}>{isReply ? capitalize(t('reply')) : capitalize(t('post'))}</div>
|
||||
<div className={styles.image}>{hasThumbnail ? t('yes') : t('no')}</div>
|
||||
<div className={styles.actions}>{renderActions()}</div>
|
||||
<div className={styles.actions}>
|
||||
<ModQueueActions
|
||||
status={status}
|
||||
errorMessage={errorMessage}
|
||||
isPublishing={isPublishing}
|
||||
handleApprove={handleApprove}
|
||||
handleReject={handleReject}
|
||||
variant='row'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -326,51 +364,6 @@ const ModQueueCard = ({ comment }: ModQueueCardProps) => {
|
||||
const threadTargetCid = threadCid || cid;
|
||||
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
||||
|
||||
const renderActions = () => {
|
||||
if (status === 'approved') {
|
||||
return (
|
||||
<div className={styles.cardActions}>
|
||||
<span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return (
|
||||
<div className={styles.cardActions}>
|
||||
<span className={`${styles.button} ${styles.reject}`}>{t('rejected')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<div className={styles.cardActions}>
|
||||
<span className={`${styles.button} ${styles.reject}`}>
|
||||
{t('failed')}
|
||||
{errorMessage ? `: ${errorMessage}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isPublishing) {
|
||||
return (
|
||||
<div className={styles.cardActions}>
|
||||
<LoadingEllipsis string={t('publishing')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.cardActions}>
|
||||
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.mobileCard}>
|
||||
<div className={styles.cardHeader}>
|
||||
@@ -396,7 +389,7 @@ const ModQueueCard = ({ comment }: ModQueueCardProps) => {
|
||||
)}{' '}
|
||||
/ {t('type')}: {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
|
||||
</div>
|
||||
{renderActions()}
|
||||
<ModQueueActions status={status} errorMessage={errorMessage} isPublishing={isPublishing} handleApprove={handleApprove} handleReject={handleReject} variant='card' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -646,7 +639,7 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
|
||||
return <ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
|
||||
};
|
||||
|
||||
export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
||||
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const { selectedBoardFilter, viewMode } = useModQueueStore();
|
||||
@@ -707,7 +700,6 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV
|
||||
postsPerPage: 50,
|
||||
});
|
||||
|
||||
// Register reset function with feed reset store so refresh button works
|
||||
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||
useEffect(() => {
|
||||
setResetFunction(reset);
|
||||
|
||||
Reference in New Issue
Block a user