refactor(react-doctor): raise score from 79 to 90

This commit is contained in:
plebeius
2026-02-24 15:15:44 +08:00
parent 5bd1dc0e71
commit e51f5b968c
36 changed files with 2372 additions and 1030 deletions
+1 -10
View File
@@ -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();
+12 -1
View File
@@ -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>
);
};
+12 -1
View File
@@ -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>
+80 -6
View File
@@ -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>
+167 -165
View File
@@ -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 && (
+180 -12
View File
@@ -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>
+4 -2
View File
@@ -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(() => {
+44 -30
View File
@@ -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>
);
};
+43 -16
View File
@@ -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>
);
};
+105 -43
View File
@@ -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>
+276 -121
View File
@@ -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 &&
+73 -41
View File
@@ -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}
+7 -7
View File
@@ -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>
]
+6 -2
View File
@@ -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);
+401 -97
View File
@@ -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>
)}
+46 -1
View File
@@ -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);
+84 -92
View File
@@ -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);