fix(codebase audit): preserve cleanup without regressions

Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
This commit is contained in:
Tommaso Casaburi
2026-04-24 15:48:07 +07:00
committed by GitHub
parent 5df994b2c7
commit 5dc5408a15
70 changed files with 1478 additions and 518 deletions
+22 -31
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { useAccount, useCommunity } from '@bitsocial/bitsocial-react-hooks';
import { initSnow, removeSnow } from './lib/snow';
@@ -31,9 +32,7 @@ import {
} from './lib/utils/route-utils';
import styles from './app.module.css';
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons';
import Board from './views/board';
import Blotter from './views/blotter';
import Catalog from './views/catalog';
import FAQ from './views/faq';
import Home from './views/home';
import Archive from './views/archive/archive';
@@ -180,7 +179,27 @@ const BoardLayout = () => {
const GlobalLayout = () => {
useTheme();
const { activeCid, parentNumber, threadNumber, threadCid, communityAddress: activeCommunityAddress, closeModal, showReplyModal, scrollY } = useReplyModalStore();
const {
activeCid,
parentNumber,
threadNumber,
threadCid,
communityAddress: activeCommunityAddress,
closeModal,
showReplyModal,
scrollY,
} = useReplyModalStore(
useShallow((state) => ({
activeCid: state.activeCid,
parentNumber: state.parentNumber,
threadNumber: state.threadNumber,
threadCid: state.threadCid,
communityAddress: state.communityAddress,
closeModal: state.closeModal,
showReplyModal: state.showReplyModal,
scrollY: state.scrollY,
})),
);
const location = useLocation();
const isInSettingsView = location.pathname.endsWith('/settings');
@@ -215,34 +234,6 @@ const GlobalLayout = () => {
);
};
/** Wraps Board with viewType/boardIdentifier derived from current route. Used when infinite scroll is OFF. */
const BoardFeedRoute = () => {
const location = useLocation();
const params = useParams();
const viewType: 'all' | 'subs' | 'mod' | 'board' = isAllView(location.pathname)
? 'all'
: isSubscriptionsView(location.pathname, params)
? 'subs'
: isModView(location.pathname)
? 'mod'
: 'board';
return <Board viewType={viewType} boardIdentifier={params.boardIdentifier} />;
};
/** Wraps Catalog with viewType/boardIdentifier derived from current route. Used when infinite scroll is OFF. */
const CatalogFeedRoute = () => {
const location = useLocation();
const params = useParams();
const viewType: 'all' | 'subs' | 'mod' | 'board' = isAllView(location.pathname)
? 'all'
: isSubscriptionsView(location.pathname, params)
? 'subs'
: isModView(location.pathname)
? 'mod'
: 'board';
return <Catalog viewType={viewType} boardIdentifier={params.boardIdentifier} />;
};
const ModQueueRoute = () => {
const { boardIdentifier } = useParams();
const account = useAccount();
@@ -209,6 +209,8 @@ const clickButton = async (text: string) => {
});
};
const findButtonLink = (text: string) => Array.from(container.querySelectorAll<HTMLAnchorElement>('a.button')).find((candidate) => candidate.textContent === text);
const changeSelect = async (select: HTMLSelectElement, value: string) => {
await act(async () => {
select.value = value;
@@ -282,6 +284,7 @@ describe('BoardButtons', () => {
expect(container.querySelector('[data-testid="mod-queue-button"]')?.textContent).toBe('mu');
expect(container.textContent).toContain('subscribe');
expect(container.textContent).toContain('vote');
expect(findButtonLink('catalog')?.getAttribute('href')).toBe('/mu/catalog');
const searchInput = container.querySelector<HTMLInputElement>('input[type="text"]');
expect(searchInput).toBeTruthy();
@@ -317,6 +320,7 @@ describe('BoardButtons', () => {
expect(container.textContent).not.toContain('archive');
expect(container.querySelector('[data-testid="catalog-filters"]')?.textContent).toBe('catalog-filters');
expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search');
expect(findButtonLink('return')?.getAttribute('href')).toBe('/all?t=24h');
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
expect(selects).toHaveLength(5);
@@ -429,8 +433,7 @@ describe('BoardButtons', () => {
await renderWithRoute(createElement(MobileBoardButtons), '/mod/queue');
const returnLink = container.querySelector<HTMLAnchorElement>('a[href="/mod"]');
expect(returnLink?.getAttribute('href')).toBe('/mod');
expect(findButtonLink('return')?.getAttribute('href')).toBe('/mod');
const thresholdInput = container.querySelector<HTMLInputElement>('input[type="number"]');
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
@@ -6,7 +6,8 @@
margin-bottom: 7px;
}
.mobileBoardButtons button {
.mobileBoardButtons button,
.mobileBoardButtons a[class~='button'] {
text-transform: capitalize;
margin: 5px 2px;
}
@@ -33,7 +34,7 @@
cursor: not-allowed !important;
}
.mobileBoardButtons a, .desktopBoardButtons a {
.mobileBoardButtons a:not([class~='button']), .desktopBoardButtons a:not([class~='button']) {
all: unset;
}
@@ -147,4 +148,4 @@
.mobileBoardButtons {
display: none;
}
}
}
@@ -80,9 +80,9 @@ export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isI
};
return (
<button className='button'>
<Link to={createCatalogLink()}>{t('catalog')}</Link>
</button>
<Link className='button' to={createCatalogLink()}>
{t('catalog')}
</Link>
);
};
@@ -151,9 +151,9 @@ export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isIn
};
return (
<button className='button'>
<Link to={createReturnLink()}>{t('return')}</Link>
</button>
<Link className='button' to={createReturnLink()}>
{t('return')}
</Link>
);
};
@@ -125,9 +125,9 @@ const BoardsBarEditModal = () => {
}}
onClick={handleBackdropClick}
>
<div className={styles.boardsbarEditDialog}>
<div className={styles.boardsbarEditDialog} role='dialog' aria-modal='true' aria-labelledby='boards-bar-edit-modal-title'>
<div className={styles.hd}>
<h2>Custom Board List</h2>
<h2 id='boards-bar-edit-modal-title'>Custom Board List</h2>
<button type='button' className={styles.closeButton} onClick={closeBoardsBarEditModal} title='Close' aria-label='Close' />
</div>
<div className={styles.bd}>
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import FiltersProtip from './filters-protip';
@@ -47,11 +48,13 @@ const toCatalogFilterItem = (item: CatalogFilterItemInput): CatalogFilterItemSto
const FiltersTable = ({ onSave }: { onSave: () => void }) => {
const { t } = useTranslation();
const { currentCommunityAddress, filterItems, saveAndApplyFilters } = useCatalogFiltersStore((state) => ({
currentCommunityAddress: state.currentCommunityAddress,
filterItems: state.filterItems as CatalogFilterItemInput[],
saveAndApplyFilters: state.saveAndApplyFilters,
}));
const { currentCommunityAddress, filterItems, saveAndApplyFilters } = useCatalogFiltersStore(
useShallow((state) => ({
currentCommunityAddress: state.currentCommunityAddress,
filterItems: state.filterItems as CatalogFilterItemInput[],
saveAndApplyFilters: state.saveAndApplyFilters,
})),
);
const resetFeed = useFeedResetStore((state) => state.reset);
const [localFilterItems, setLocalFilterItems] = useState(() =>
+2 -2
View File
@@ -6,7 +6,7 @@ import { useFloating, offset, size, Placement } from '@floating-ui/react';
import { Comment, useReplies } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
import { shouldShowSnow } from '../../lib/snow';
import { getHasThumbnail } from '../../lib/utils/media-utils';
import { CommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { findDirectoryByAddress, useDirectories } from '../../hooks/use-directories';
@@ -27,7 +27,7 @@ import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from
interface CatalogPostMediaProps {
cid: string;
commentMediaInfo: any;
commentMediaInfo: CommentMediaInfo | undefined;
isOutOfFeed?: boolean;
linkWidth?: number;
linkHeight?: number;
@@ -220,7 +220,7 @@ describe('ChallengeModal', () => {
type: 'text/plain',
},
{
challenge: 'base64-image',
challenge: 'YmFzZTY0LWltYWdl',
type: 'image/png',
},
],
@@ -238,7 +238,7 @@ describe('ChallengeModal', () => {
});
expect(container.textContent).toContain('2/2');
expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,base64-image');
expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,YmFzZTY0LWltYWdl');
await clickButton('previous');
expect(container.textContent).toContain('1/2');
@@ -271,6 +271,7 @@ describe('ChallengeModal', () => {
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute('src')).toContain('https://mintpass.org/auth?user=0xabc123&theme=dark');
expect(iframe?.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation');
await act(async () => {
iframe?.dispatchEvent(new Event('load', { bubbles: true }));
@@ -438,7 +439,7 @@ describe('ChallengeModal', () => {
await renderModal();
await clickButton('Open');
expect(alertSpy).toHaveBeenCalledWith('Error: Invalid URL for authentication challenge');
expect(alertSpy).toHaveBeenCalledWith('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported');
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
await act(async () => {
@@ -24,7 +24,15 @@ interface ChallengeProps {
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}`} />;
const MAX_IMAGE_CHALLENGE_BASE64_LENGTH = 2_000_000;
const isSafeBase64ImageChallenge = (challenge: string) => /^[A-Za-z0-9+/]*={0,2}$/.test(challenge) && challenge.length <= MAX_IMAGE_CHALLENGE_BASE64_LENGTH;
const ImageChallenge = ({ challenge }: { challenge: string }) =>
isSafeBase64ImageChallenge(challenge) ? (
<img alt='Challenge' className={styles.challengeMedia} src={`data:image/png;base64,${challenge}`} />
) : (
<div className={styles.challengeMedia}>Invalid image challenge</div>
);
const isLocalIframeHostname = (hostname: string) => hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname.endsWith('.localhost');
@@ -98,7 +106,9 @@ const IframeChallenge = ({
const isHttps = validatedUrl.protocol === 'https:';
const isLocalHttp = validatedUrl.protocol === 'http:' && isLocalIframeHostname(validatedUrl.hostname);
if (!isHttps && !isLocalHttp) {
throw new Error('Only HTTPS iframe challenges or localhost HTTP challenges are supported');
alert('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported');
onCancel();
return;
}
validatedUrl.pathname = validatedUrl.pathname.replace(/\/{2,}/g, '/');
validatedUrl.searchParams.set('theme', theme);
@@ -358,15 +368,18 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
<animated.div
className={containerClasses.join(' ')}
ref={nodeRef}
role='dialog'
aria-modal='true'
aria-labelledby='challenge-modal-title'
style={{
x: isMobile ? mobileX : x.to((value) => Math.round(value)),
y: isMobile ? mobileY : y.to((value) => Math.round(value)),
touchAction: 'none',
}}
>
<div className={`challengeHandle ${styles.title}`} {...(!isMobile ? bind() : {})}>
<div id='challenge-modal-title' className={`challengeHandle ${styles.title}`} {...(!isMobile ? bind() : {})}>
Challenge for {publicationType}
<button className={styles.closeIcon} onClick={abandonModal} title='close' />
<button type='button' className={styles.closeIcon} onClick={abandonModal} title='close' aria-label={t('close')} />
</div>
<div className={styles.publication}>
{isIframeChallenge ? (
@@ -32,6 +32,14 @@ vi.mock('../../../lib/utils/media-utils', () => ({
vi.mock('../../../lib/utils/url-utils', () => ({
getHostname: () => testState.hostname,
parseHttpUrl: (value: string) => {
try {
const parsedUrl = new URL(value);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl : null;
} catch {
return null;
}
},
}));
vi.mock('../../../stores/use-expanded-media-store', () => {
@@ -141,14 +141,14 @@
.mediaMobile video,
.mediaMobile iframe,
.mediaMobile audio {
padding: 3px 0 5px 0;
padding: 0;
}
.mediaDesktopReply img,
.mediaDesktopReply video,
.mediaDesktopReply iframe,
.mediaDesktopReply audio {
padding: 3px 20px 5px 20px;
padding: 0;
}
.mediaDesktopOp {
@@ -160,7 +160,7 @@
.mediaDesktopOp video,
.mediaDesktopOp iframe,
.mediaDesktopOp audio {
padding: 3px 20px 5px 20px;
padding: 0;
max-width: 100%;
box-sizing: border-box;
}
@@ -1,7 +1,7 @@
import React, { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
import { getHostname } from '../../lib/utils/url-utils';
import { getHostname, parseHttpUrl } from '../../lib/utils/url-utils';
import useExpandedMediaStore from '../../stores/use-expanded-media-store';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useIsMobile from '../../hooks/use-is-mobile';
@@ -47,6 +47,7 @@ const Thumbnail = ({
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
let thumbnailComponent: React.ReactNode = null;
const thumbnailDimensions = { '--width': displayWidth, '--height': displayHeight } as React.CSSProperties;
const iframeThumbnail = patternThumbnailUrl || thumbnail;
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
@@ -139,9 +140,8 @@ const Thumbnail = ({
}
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
const thumbnailDimensions = { '--width': displayWidth, '--height': displayHeight } as React.CSSProperties;
const linkWithoutThumbnail = url && new URL(url);
const linkWithoutThumbnail = url ? parseHttpUrl(url) : null;
const fallbackLinkLabel = url ? getHostname(url) || (url.length > 30 ? `${url.slice(0, 30)}...` : url) : '';
const noThumbnailLink =
!hasThumbnail && linkWithoutThumbnail ? (
@@ -160,7 +160,7 @@ const Thumbnail = ({
{fallbackLinkLabel}
</span>
) : (
<a href={url} target='_blank' rel='noreferrer'>
<a href={url} target='_blank' rel='noopener noreferrer'>
{fallbackLinkLabel}
</a>
)
@@ -28,10 +28,10 @@ const CreateBoardModal = () => {
}}
onClick={handleBackdropClick}
>
<div className={styles.createBoardDialog}>
<div className={styles.createBoardDialog} role='dialog' aria-modal='true' aria-labelledby='create-board-modal-title'>
<div className={styles.hd}>
<h2>Create a Board</h2>
<button className={styles.closeButton} onClick={closeCreateBoardModal} title='Close' />
<h2 id='create-board-modal-title'>Create a Board</h2>
<button className={styles.closeButton} onClick={closeCreateBoardModal} title='Close' aria-label='Close' />
</div>
<div className={styles.bd}>
<div className={styles.section}>
@@ -30,10 +30,10 @@ const DirectoryModal = () => {
}}
onClick={handleBackdropClick}
>
<div className={styles.directoryDialog}>
<div className={styles.directoryDialog} role='dialog' aria-modal='true' aria-labelledby='directory-modal-title'>
<div className={styles.hd}>
<h2>Submit a Board to a Directory</h2>
<button className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`} onClick={closeDirectoryModal} title='Close' />
<h2 id='directory-modal-title'>Submit a Board to a Directory</h2>
<button className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`} onClick={closeDirectoryModal} title='Close' aria-label='Close' />
</div>
<div className={styles.bd}>
<p className={styles.introMessage}>
@@ -43,10 +43,10 @@ const DisclaimerModal = () => {
}}
onClick={handleBackdropClick}
>
<div className={styles.disclaimerDialog}>
<div className={styles.disclaimerDialog} role='dialog' aria-modal='true' aria-labelledby='disclaimer-modal-title'>
<div className={styles.hd}>
<h2>Disclaimer</h2>
<button className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`} onClick={closeDisclaimerModal} title='Close' />
<h2 id='disclaimer-modal-title'>Disclaimer</h2>
<button className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`} onClick={closeDisclaimerModal} title='Close' aria-label='Close' />
</div>
<div className={styles.bd}>
<p>This web application may contain content for mature audiences only. By clicking &quot;Accept,&quot; you confirm that:</p>
+7 -1
View File
@@ -46,6 +46,8 @@ interface EmbedComponentProps {
parsedUrl: URL;
}
const srcDocSandbox = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
const youtubeHosts = new Set<string>([
'youtube.com',
'www.youtube.com',
@@ -116,6 +118,7 @@ const XEmbed = ({ parsedUrl }: EmbedComponentProps) => {
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
sandbox={srcDocSandbox}
title={parsedUrl.href}
srcDoc={`
<blockquote class="twitter-tweet" data-theme="dark">
@@ -137,6 +140,7 @@ const RedditEmbed = ({ parsedUrl }: EmbedComponentProps) => {
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
sandbox={srcDocSandbox}
title={parsedUrl.href}
srcDoc={`
<style>
@@ -190,6 +194,7 @@ const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => {
width='100%'
referrerPolicy='no-referrer'
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
sandbox={srcDocSandbox}
title={parsedUrl.href}
srcDoc={`
<blockquote class="tiktok-embed" data-video-id="${videoId}">
@@ -218,8 +223,9 @@ const InstagramEmbed = ({ parsedUrl }: EmbedComponentProps) => {
<blockquote class="instagram-media">
<a href="https://www.instagram.com/p/${id}/"></a>
</blockquote>
<script async src="//www.instagram.com/embed.js"></script>
<script async src="https://www.instagram.com/embed.js"></script>
`}
sandbox={srcDocSandbox}
/>
);
};
@@ -68,7 +68,8 @@ const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => {
const FeedCacheContainer = () => {
const location = useLocation();
const { cachedFeeds, accessFeed } = useFeedCacheStore();
const cachedFeeds = useFeedCacheStore((state) => state.cachedFeeds);
const accessFeed = useFeedCacheStore((state) => state.accessFeed);
const currentFeedKey = getFeedCacheKey(location.pathname, location.search);
const isOnFeedRoute = isFeedRoute(location.pathname);
+1 -1
View File
@@ -175,7 +175,7 @@
padding: 8px 8px;
}
.mobileFooterButtons a {
.mobileFooterButtons a:not([class~='button']) {
all: unset;
}
+40 -39
View File
@@ -34,6 +34,7 @@ import LoadingEllipsis from '../loading-ellipsis';
import PostMenuDesktop from './post-menu-desktop';
import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
import TimeAgoTooltip from '../time-ago-tooltip';
import { PostProps } from '../../views/post/post';
import { create } from 'zustand';
import capitalize from 'lodash/capitalize';
@@ -244,8 +245,8 @@ const PostInfo = ({
const location = useLocation();
const isInPostPageView = isPostPageView(location.pathname, params);
const isInModQueueView = isModQueueView(location.pathname);
const { getAlertThresholdSeconds } = useModQueueStore();
const currentTime = useCurrentTime();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const currentTime = useCurrentTime(isInModQueueView ? 60 : false);
const account = useAccount();
const accountAddress = account?.author?.address;
@@ -282,22 +283,26 @@ const PostInfo = ({
return Math.max(postsByAuthorInThread?.get(shortAddress) ?? 0, 1);
})();
const { hidden } = useHide(post);
const { hidden } = useHide({ cid: cid || '' });
const { openReplyModal } = useReplyModalStore();
const onReplyModalClick = () => {
deleted
? isReply
? alert(t('this_reply_was_deleted'))
: alert(t('this_thread_was_deleted'))
: removed || purged
? isReply
? alert(t('this_reply_was_removed'))
: alert(t('this_thread_was_removed'))
: archived && !isReply
? alert(t('thread_archived'))
: openReplyModal && openReplyModal(cid, post?.number, postCid, threadNumber, communityAddress);
if (deleted) {
alert(t(isReply ? 'this_reply_was_deleted' : 'this_thread_was_deleted'));
return;
}
if (removed || purged) {
alert(t(isReply ? 'this_reply_was_removed' : 'this_thread_was_removed'));
return;
}
if (archived && !isReply) {
alert(t('thread_archived'));
return;
}
if (cid && postCid && communityAddress && openReplyModal) {
openReplyModal(cid, post?.number, postCid, threadNumber, communityAddress);
}
};
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
@@ -329,7 +334,7 @@ const PostInfo = ({
return (
<div className={styles.postInfo} data-post-info-cid={cid}>
{isHidden ? parentCid && <span className={styles.hiddenReplyEditMenuSpacer} /> : <EditMenu post={post} />}
{isHidden ? parentCid && <span className={styles.hiddenReplyEditMenuSpacer} /> : post ? <EditMenu post={post} /> : null}
<span className={(hidden || ((removed || deleted || purged) && !reason)) && parentCid ? styles.postDesktopHidden : ''}>
{title &&
(title.length <= 75 ? (
@@ -410,15 +415,15 @@ const PostInfo = ({
<span className={styles.dateTime}>
{isInModQueueView && isOverThreshold ? (
<>
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<TimeAgoTooltip timestamp={timestamp}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>{' '}
</TimeAgoTooltip>{' '}
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<TimeAgoTooltip timestamp={timestamp}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>
</TimeAgoTooltip>
)}{' '}
</span>
<span className={styles.postNum}>
@@ -529,7 +534,7 @@ const PostInfo = ({
{shouldShowPendingApprovalButtons && communityAddress && cid && <PendingModerationActions cid={cid} communityAddress={communityAddress} post={post} />}
</span>
{!(removed || deleted || purged) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
{cid && parentCid && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
{post && cid && parentCid && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
{cid && !parentCid && <OpBacklinks cid={cid} quotedByMap={quotedByMap} />}
</span>
</div>
@@ -589,13 +594,13 @@ const OpBacklinks = ({ cid, quotedByMap }: { cid: string; quotedByMap?: Map<stri
interface PostMediaProps {
commentMediaInfo: CommentMediaInfo | undefined;
hasThumbnail: boolean;
spoiler: boolean;
deleted: boolean;
spoiler?: boolean;
deleted?: boolean;
purged: boolean;
removed: boolean;
linkHeight: number;
linkWidth: number;
parentCid: string;
removed?: boolean;
linkHeight?: number;
linkWidth?: number;
parentCid?: string;
communityAddress?: string;
isInAllView: boolean;
isInSubscriptionsView: boolean;
@@ -659,7 +664,8 @@ const PostMedia = ({
if (spoiler) return capitalize(t('spoiler'));
if (requirePostLinkIsMedia && url) {
try {
const filename = new URL(url).pathname.split('/').pop();
const pathParts = new URL(url).pathname.split('/');
const filename = pathParts[pathParts.length - 1];
if (filename && /\.\w+$/.test(filename)) return truncateWithEllipsisInMiddle(filename);
} catch {}
}
@@ -809,7 +815,7 @@ const Reply = ({
isInModView={isInModView}
/>
)}
{!hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && (
{post && !hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && (
<CommentContent comment={post} prependContent={failedPublishNotice} />
)}
</div>
@@ -836,8 +842,7 @@ const PostDesktop = ({
}: PostProps) => {
const { t } = useTranslation();
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, communityAddress, thumbnailUrl, parentCid } =
resolvedPost || {};
const { author, cid, content, deleted, link, linkHeight, linkWidth, postCid, removed, spoiler, state, communityAddress, thumbnailUrl, parentCid } = resolvedPost || {};
const purged = resolvedPost?.commentModeration?.purged;
const params = useParams();
const location = useLocation();
@@ -876,9 +881,7 @@ const PostDesktop = ({
repliesPerPage: BOARD_REPLIES_PREVIEW_FETCH_SIZE,
accountComments: { newerThan: Infinity, append: true },
});
const cachedPreviewReplies = (cachedPreviewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (cachedPreviewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: cachedPreviewRepliesResult.replies || [];
const cachedPreviewReplies = cachedPreviewRepliesResult.updatedReplies?.length ? cachedPreviewRepliesResult.updatedReplies : cachedPreviewRepliesResult.replies || [];
const hasEnoughCachedPreview = hasEnoughPreviewReplies({
replyCount: resolvedPost?.replyCount,
loadedCount: cachedPreviewReplies.length,
@@ -899,14 +902,12 @@ const PostDesktop = ({
accountComments: { newerThan: Infinity, append: true },
});
const livePreviewReplies = (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: previewRepliesResult.replies || [];
const livePreviewReplies = previewRepliesResult.updatedReplies?.length ? previewRepliesResult.updatedReplies : previewRepliesResult.replies || [];
const previewReplies = hasReplyPaginationOverride ? replyPaginationOverride.replies : hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const fullReplies = hasReplyPaginationOverride
? replyPaginationOverride.replies
: (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: fullRepliesResult.updatedReplies?.length
? fullRepliesResult.updatedReplies
: fullRepliesResult.replies || [];
const hasMore = replyPaginationOverride?.hasMore ?? fullRepliesResult.hasMore;
@@ -1191,7 +1192,7 @@ const PostDesktop = ({
directRepliesByParentCid={directRepliesByParentCid}
/>
{!isHidden && !content && !(deleted || removed || purged) && <div className={styles.spacer} />}
{!isHidden && <CommentContent comment={resolvedPost} prependContent={failedPublishNotice} />}
{resolvedPost && !isHidden && <CommentContent comment={resolvedPost} prependContent={failedPublishNotice} />}
</div>
{!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && (
<span className={styles.summary}>
@@ -215,9 +215,9 @@ describe('PostMenuDesktop', () => {
const hrefs = Array.from(document.body.querySelectorAll('a')).map((link) => link.getAttribute('href'));
expect(hrefs).toEqual(
expect.arrayContaining([
'https://lens.google.com/uploadbyurl?url=https://cdn.example/image.png',
'https://www.yandex.com/images/search?url=https://cdn.example/image.png&rpt=imageview',
'https://saucenao.com/search.php?url=https://cdn.example/image.png',
'https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fcdn.example%2Fimage.png',
'https://www.yandex.com/images/search?img_url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
'https://saucenao.com/search.php?url=https%3A%2F%2Fcdn.example%2Fimage.png',
]),
);
});
@@ -9,7 +9,7 @@ import { copyShareLinkToClipboard, isValidURL, type ShareLinkType } from '../../
import { copyToClipboard } from '../../../lib/utils/clipboard-utils';
import { getBoardPath } from '../../../lib/utils/route-utils';
import { useDirectories } from '../../../hooks/use-directories';
import { isAllView, isCatalogView, isPostPageView, isSubscriptionsView } from '../../../lib/utils/view-utils';
import { isCatalogView, isPostPageView } from '../../../lib/utils/view-utils';
import useHide from '../../../hooks/use-hide';
import capitalize from 'lodash/capitalize';
import { PostMenuProps } from '../../../lib/utils/post-menu-props';
@@ -119,6 +119,7 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
const ImageSearchButton = ({ url, onClose }: { url: string; onClose: () => void }) => {
const { t } = useTranslation();
const [isImageSearchMenuOpen, setIsImageSearchMenuOpen] = useState(false);
const encodedUrl = encodeURIComponent(url);
const { refs, floatingStyles } = useFloating({
placement: 'right-start',
@@ -144,13 +145,13 @@ const ImageSearchButton = ({ url, onClose }: { url: string; onClose: () => void
{capitalize(t('image_search'))} »
{isImageSearchMenuOpen && (
<div ref={refs.setFloating} style={floatingStyles} className={styles.dropdownMenu}>
<a href={`https://lens.google.com/uploadbyurl?url=${url}`} target='_blank' rel='noreferrer'>
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
<div className={styles.postMenuItem}>Google</div>
</a>
<a href={`https://www.yandex.com/images/search?url=${url}&rpt=imageview`} target='_blank' rel='noreferrer'>
<a href={`https://www.yandex.com/images/search?img_url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer'>
<div className={styles.postMenuItem}>Yandex</div>
</a>
<a href={`https://saucenao.com/search.php?url=${url}`} target='_blank' rel='noreferrer'>
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
<div className={styles.postMenuItem}>SauceNAO</div>
</a>
</div>
@@ -174,10 +175,8 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
const location = useLocation();
const params = useParams();
const isInAllView = isAllView(location.pathname);
const isInCatalogView = isCatalogView(location.pathname, params);
const isInPostPageView = isPostPageView(location.pathname, params);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
const { refs, floatingStyles, context } = useFloating({
placement: 'bottom-start',
@@ -249,13 +248,21 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
role='button'
tabIndex={0}
onClick={() => {
hidden ? unhide() : hide();
if (hidden) {
unhide();
} else {
hide();
}
handleClose();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
hidden ? unhide() : hide();
if (hidden) {
unhide();
} else {
hide();
}
handleClose();
}
}}
+19 -11
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
@@ -52,7 +53,7 @@ const PostFormActions = ({
}: {
disableReplyPublish?: boolean;
variant: 'reply' | 'post' | 'upload';
t: (key: string) => string;
t: TFunction;
isInPostView: boolean;
onPublishReply: () => void;
onPublishPost: () => void;
@@ -62,17 +63,21 @@ const PostFormActions = ({
}) => {
if (variant === 'reply' && isInPostView) {
return (
<button onClick={onPublishReply} disabled={disableReplyPublish || isUploading}>
<button type='button' onClick={onPublishReply} disabled={disableReplyPublish || isUploading}>
{t('post')}
</button>
);
}
if (variant === 'post' && !isInPostView) {
return <button onClick={onPublishPost}>{t('post')}</button>;
return (
<button type='button' onClick={onPublishPost}>
{t('post')}
</button>
);
}
if (variant === 'upload' && showUploadControls) {
return (
<button onClick={handleUpload} disabled={isUploading}>
<button type='button' onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
);
@@ -81,7 +86,7 @@ const PostFormActions = ({
};
interface PostFormFieldsProps {
t: (key: string) => string;
t: TFunction;
account: ReturnType<typeof useAccount>;
displayName: string | undefined;
isInPostView: boolean;
@@ -151,6 +156,7 @@ const PostFormFields = ({
<td>
<input
type='text'
aria-label={t('name')}
placeholder={!displayName ? capitalize(t('anonymous')) : undefined}
defaultValue={displayName || undefined}
onChange={(e) => {
@@ -182,6 +188,7 @@ const PostFormFields = ({
<td>
<input
type='text'
aria-label={t('subject')}
ref={subjectRef}
onChange={(e) => {
setPublishPostOptions({ title: e.target.value });
@@ -204,7 +211,7 @@ const PostFormFields = ({
<tr>
<td>{t('comment')}</td>
<td>
<textarea cols={48} rows={4} wrap='soft' ref={textRef} onChange={handleContentChange} />
<textarea cols={48} rows={4} wrap='soft' ref={textRef} aria-label={t('comment')} onChange={handleContentChange} />
{lengthError && <div className={styles.error}>{lengthError}</div>}
</td>
</tr>
@@ -213,6 +220,7 @@ const PostFormFields = ({
<td className={styles.linkField}>
<input
type='text'
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
@@ -269,7 +277,7 @@ const PostFormFields = ({
<tr>
<td>{t('board')}</td>
<td>
<select onChange={(e) => setPublishPostOptions({ communityAddress: e.target.value })} value={communityAddress}>
<select aria-label={t('board')} onChange={(e) => setPublishPostOptions({ communityAddress: e.target.value })} value={communityAddress}>
<option value=''>{t('choose_one')}</option>
{isInAllView &&
directories
@@ -334,7 +342,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const [lengthError, setLengthError] = useState<string | null>(null);
const checkContentLength = useRef(
debounce((content: string, t: Function) => {
debounce((content: string, t: TFunction) => {
const length = content.trim().length;
if (length > 2000) {
setLengthError(`${t('error')}: ${t('comment_field_too_long', { length })}`);
@@ -398,7 +406,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
// in post page, publish a reply to the post
const isInPostView = isPostPageView(location.pathname, params);
const cid = params?.commentCid as string;
const cid = params?.commentCid || '';
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
usePublishReply({ cid, communityAddress, postCid });
@@ -533,8 +541,8 @@ const PostForm = () => {
const isMobile = useIsMobile();
const commentCid = params?.commentCid;
const post = useCommunitiesPagesStore((state) => state.comments[commentCid as string]);
let comment: Comment = post;
const post = useCommunitiesPagesStore((state) => (commentCid ? state.comments[commentCid] : undefined));
let comment: Comment | undefined = post;
// handle pending mod or author edit
const { editedComment } = useEditedComment({ comment });
if (editedComment) {
@@ -255,9 +255,9 @@ describe('PostMenuMobile', () => {
const hrefs = Array.from(document.body.querySelectorAll('a')).map((link) => link.getAttribute('href'));
expect(hrefs).toEqual(
expect.arrayContaining([
'https://lens.google.com/uploadbyurl?url=https://cdn.example/image.png',
'https://www.yandex.com/images/search?url=https://cdn.example/image.png&rpt=imageview',
'https://saucenao.com/search.php?url=https://cdn.example/image.png',
'https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fcdn.example%2Fimage.png',
'https://www.yandex.com/images/search?img_url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
'https://saucenao.com/search.php?url=https%3A%2F%2Fcdn.example%2Fimage.png',
]),
);
});
@@ -14,11 +14,11 @@ import useEditCommentPrivileges from '../../../hooks/use-author-privileges';
import { useBoardPseudonymityMode } from '../../../hooks/use-board-pseudonymity-mode';
import useHide from '../../../hooks/use-hide';
import EditMenu from '../../edit-menu/edit-menu';
import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils';
import { isPostPageView } from '../../../lib/utils/view-utils';
import { useLocation, useParams } from 'react-router-dom';
import { PostMenuProps } from '../../../lib/utils/post-menu-props';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../../lib/utils/comment-utils';
import { alertChallengeVerificationFailed } from '../../../lib/utils/challenge-utils';
import { alertChallengeVerificationFailed, type ChallengePublication } from '../../../lib/utils/challenge-utils';
import useChallengesStore from '../../../stores/use-challenges-store';
async function copyShareLinkSafe(boardIdentifier: string, linkType: ShareLinkType, cid?: string): Promise<void> {
@@ -133,6 +133,7 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void }) => {
const { t } = useTranslation();
const encodedUrl = encodeURIComponent(url);
return (
<div
role='button'
@@ -145,13 +146,13 @@ const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void
}
}}
>
<a href={`https://lens.google.com/uploadbyurl?url=${url}`} target='_blank' rel='noreferrer'>
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
<div className={styles.postMenuItem}>{t('search_image_on_google')}</div>
</a>
<a href={`https://www.yandex.com/images/search?url=${url}&rpt=imageview`} target='_blank' rel='noreferrer'>
<a href={`https://www.yandex.com/images/search?img_url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer'>
<div className={styles.postMenuItem}>{t('search_image_on_yandex')}</div>
</a>
<a href={`https://saucenao.com/search.php?url=${url}`} target='_blank' rel='noreferrer'>
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
<div className={styles.postMenuItem}>{t('search_image_on_saucenao')}</div>
</a>
</div>
@@ -200,6 +201,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
postCid: resolvedPost?.postCid,
});
const signer = isAccountCommentAuthor ? account?.signer : undefined;
const signerMatchesAuthor = Boolean(signer?.address && author?.address && signer.address === author.address);
const latestPostRef = useRef(resolvedPost);
useEffect(() => {
latestPostRef.current = resolvedPost;
@@ -216,11 +218,11 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
...(isAccountCommentAuthor && signer
? {
signer,
author: signer?.address === author?.address ? { address: signer.address, displayName: resolvedPost?.author?.displayName } : account?.author,
author: signerMatchesAuthor ? { address: signer.address, displayName: resolvedPost?.author?.displayName } : account?.author,
}
: {}),
onChallenge,
onChallengeVerification: async (challengeVerification: ChallengeVerification, publication: unknown) => {
onChallengeVerification: async (challengeVerification: ChallengeVerification, publication: ChallengePublication | undefined) => {
alertChallengeVerificationFailed(challengeVerification, publication);
},
onError: (error: Error) => {
@@ -228,7 +230,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
alert('Comment edit failed. ' + error.message);
},
}),
[cid, communityAddress, isAccountCommentAuthor, signer, author?.address, resolvedPost?.author?.displayName, account?.author, onChallenge],
[cid, communityAddress, isAccountCommentAuthor, signer, signerMatchesAuthor, resolvedPost?.author?.displayName, account?.author, onChallenge],
);
const { publishCommentEdit } = usePublishCommentEdit(deleteOptions);
@@ -271,8 +273,12 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
const isInPostView = isPostPageView(useLocation().pathname, useParams());
const handleClick = () => {
hidden ? unhide() : hide();
onClose && onClose();
if (hidden) {
unhide();
} else {
hide();
}
onClose?.();
};
return (
(!isInPostView || isReply) && (
@@ -297,7 +303,7 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
type PostMenuMobileProps = {
postMenu: PostMenuProps;
editMenuPost: Comment;
editMenuPost?: Comment;
};
const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
@@ -332,8 +338,6 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
const handleClose = () => setIsMenuOpen(false);
const isInBoardView = isBoardView(useLocation().pathname, useParams());
return (
<>
{!(deleted || removed) && (
@@ -362,7 +366,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
<ReportPostButton onClose={handleClose} />
{cid && communityAddress && <HidePostButton cid={cid} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
{(isAccountCommentAuthor || canAttemptAuthorDelete) && cid && <DeletePostButton post={editMenuPost} onClose={handleClose} />}
{(isAccountCommentAuthor || canAttemptAuthorDelete) && cid && editMenuPost && <DeletePostButton post={editMenuPost} onClose={handleClose} />}
{cid && communityAddress && <CopyLinkButton cid={cid} communityAddress={communityAddress} linkType='thread' onClose={handleClose} />}
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
{authorAddress && <CopyUserIdButton address={authorAddress} onClose={handleClose} />}
@@ -373,7 +377,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
)}
</>
)}
{isAccountMod && cid && (
{isAccountMod && cid && editMenuPost && (
<span className={styles.checkbox}>
<EditMenu post={editMenuPost} />
</span>
+32 -31
View File
@@ -31,6 +31,7 @@ import LoadingEllipsis from '../loading-ellipsis';
import PostMenuMobile from './post-menu-mobile';
import ReplyQuotePreview from '../reply-quote-preview';
import Tooltip from '../tooltip';
import TimeAgoTooltip from '../time-ago-tooltip';
import { PostProps } from '../../views/post/post';
import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase';
@@ -99,8 +100,8 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const isInModView = isModView(location.pathname);
const isInModQueueView = isModQueueView(location.pathname);
const { getAlertThresholdSeconds } = useModQueueStore();
const currentTime = useCurrentTime();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const currentTime = useCurrentTime(isInModQueueView ? 60 : false);
const account = useAccount();
const accountAddress = account?.author?.address;
@@ -225,22 +226,26 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
const userIDBackgroundColor = hashStringToColor(userID);
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
const { hidden } = useHide(resolvedPost);
const { hidden } = useHide({ cid: cid || '' });
const { openReplyModal } = useReplyModalStore();
const onReplyModalClick = () => {
deleted
? isReply
? alert(t('this_reply_was_deleted'))
: alert(t('this_thread_was_deleted'))
: removed || purged
? isReply
? alert(t('this_reply_was_removed'))
: alert(t('this_thread_was_removed'))
: archived && !isReply
? alert(t('thread_archived'))
: openReplyModal && openReplyModal(cid, resolvedPost?.number, postCid, threadNumber, communityAddress);
if (deleted) {
alert(t(isReply ? 'this_reply_was_deleted' : 'this_thread_was_deleted'));
return;
}
if (removed || purged) {
alert(t(isReply ? 'this_reply_was_removed' : 'this_thread_was_removed'));
return;
}
if (archived && !isReply) {
alert(t('thread_archived'));
return;
}
if (cid && postCid && communityAddress && openReplyModal) {
openReplyModal(cid, resolvedPost?.number, postCid, threadNumber, communityAddress);
}
};
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
@@ -371,15 +376,15 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
)}
{isInModQueueView && isOverThreshold ? (
<>
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<TimeAgoTooltip timestamp={timestamp}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>{' '}
</TimeAgoTooltip>{' '}
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<TimeAgoTooltip timestamp={timestamp}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>
</TimeAgoTooltip>
)}{' '}
{cid ? (
<span className={styles.postNumLink}>
@@ -441,7 +446,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
);
};
const PostMediaContent = ({ post, link }: { post: any; link: string }) => {
const PostMediaContent = ({ post, link }: { post: Comment | undefined; link: string }) => {
const [showThumbnail, setShowThumbnail] = useState(true);
const { thumbnailUrl, linkWidth, linkHeight, spoiler, deleted, removed, parentCid } = post || {};
const purged = post?.commentModeration?.purged;
@@ -542,10 +547,10 @@ const Reply = ({
data-post-cid={postCid}
>
<PostInfoAndMedia post={post} postReplyCount={postReplyCount} postsByAuthorInThread={postsByAuthorInThread} roles={roles} threadNumber={threadNumber} />
{!hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && (
{post && !hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && (
<CommentContent comment={post} prependContent={failedPublishNotice} />
)}
<ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />
{post && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
</div>
</div>
</div>
@@ -571,7 +576,7 @@ const PostMobile = ({
}: PostProps) => {
const { t } = useTranslation();
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, parentCid, pinned, postCid, replyCount, state, communityAddress } = resolvedPost || {};
const { author, cid, parentCid, postCid, replyCount, state, communityAddress } = resolvedPost || {};
const params = useParams();
const location = useLocation();
const navigationType = useNavigationType();
@@ -601,9 +606,7 @@ const PostMobile = ({
repliesPerPage: BOARD_REPLIES_PREVIEW_FETCH_SIZE,
accountComments: { newerThan: Infinity, append: true },
});
const cachedPreviewReplies = (cachedPreviewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (cachedPreviewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: cachedPreviewRepliesResult.replies || [];
const cachedPreviewReplies = cachedPreviewRepliesResult.updatedReplies?.length ? cachedPreviewRepliesResult.updatedReplies : cachedPreviewRepliesResult.replies || [];
const cachedPreviewDisplayCount = filterRepliesForDisplay(cachedPreviewReplies).length;
const hasEnoughCachedPreview = hasEnoughPreviewReplies({
replyCount: resolvedPost?.replyCount,
@@ -624,9 +627,7 @@ const PostMobile = ({
repliesPerPage: REPLIES_PER_PAGE,
accountComments: { newerThan: Infinity, append: true },
});
const livePreviewReplies = (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: previewRepliesResult.replies || [];
const livePreviewReplies = previewRepliesResult.updatedReplies?.length ? previewRepliesResult.updatedReplies : previewRepliesResult.replies || [];
const previewReplies = hasReplyPaginationOverride ? replyPaginationOverride.replies : hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const repliesResult = hasReplyPaginationOverride
? {
@@ -640,7 +641,7 @@ const PostMobile = ({
? fullRepliesResult
: { ...previewRepliesResult, replies: previewReplies, updatedReplies: previewReplies };
const { replies, hasMore, loadMore } = repliesResult;
const updatedReplies = (repliesResult as { updatedReplies?: Comment[] }).updatedReplies;
const updatedReplies = repliesResult.updatedReplies;
const repliesForRender = updatedReplies?.length ? updatedReplies : replies || [];
const freshRepliesForRender = useFreshReplies(repliesForRender);
useRegisterFreshReplies(resolvedPost, freshRepliesForRender);
@@ -839,8 +840,8 @@ const PostMobile = ({
roles={roles}
threadNumber={resolvedPost?.number}
/>
<CommentContent comment={resolvedPost} prependContent={failedPublishNotice} />
<ReplyBacklinks post={resolvedPost} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />
{resolvedPost && <CommentContent comment={resolvedPost} prependContent={failedPublishNotice} />}
{resolvedPost && <ReplyBacklinks post={resolvedPost} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
</div>
{!isInPostView && !isInPendingPostView && (showReplies || isModQueue) && (
<div className={styles.postLink}>
+13 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { isValidPublishURL } from '../../lib/utils/url-utils';
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
@@ -64,7 +65,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const [lengthError, setLengthError] = useState<string | null>(null);
const checkContentLengthRef = useRef(
debounce((content: string, t: Function) => {
debounce((content: string, t: TFunction) => {
const length = content.trim().length;
if (length > 2000) {
setError(null);
@@ -291,27 +292,33 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
<animated.div
className={styles.container}
ref={nodeRef}
role='dialog'
aria-modal='true'
aria-labelledby='reply-modal-title'
style={{
left,
top,
touchAction: 'none',
}}
>
<div className={`replyModalHandle ${styles.title}`} {...(!isMobile ? bind() : {})}>
<div id='reply-modal-title' className={`replyModalHandle ${styles.title}`} {...(!isMobile ? bind() : {})}>
{t('reply_to_no', { no: threadNumber ?? '?' })}
<button
type='button'
className={styles.closeIcon}
onClick={(e) => {
e.stopPropagation();
closeModal();
}}
title='close'
aria-label={t('close')}
/>
</div>
<div className={styles.replyForm}>
<div className={styles.name}>
<input
type='text'
aria-label={t('name')}
defaultValue={displayName}
placeholder={displayName ? undefined : capitalize(t('name'))}
onChange={(e) => {
@@ -324,6 +331,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
<input
type='text'
ref={urlRef}
aria-label={requirePostLinkIsMedia ? t('link_to_file') : t('link')}
placeholder={capitalize(requirePostLinkIsMedia ? t('link_to_file') : t('link'))}
disabled={isUploading}
onChange={(e) => setPublishReplyOptions({ link: e.target.value })}
@@ -335,6 +343,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
rows={4}
wrap='soft'
ref={textRef}
aria-label={t('comment')}
spellCheck={true}
onInput={handleContentInput}
onChange={handleContentChange}
@@ -352,7 +361,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
{showUploadControls && (
<span className={styles.uploadContainer}>
<span className={styles.uploadButton}>
<button onClick={handleUpload} disabled={isUploading}>
<button type='button' onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
</span>
@@ -371,7 +380,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
]
</span>
)}
<button className={styles.publishButton} disabled={isResolvingExternalQuotes} onClick={onPublishReply}>
<button className={styles.publishButton} disabled={isResolvingExternalQuotes} type='button' onClick={onPublishReply}>
{t('post')}
</button>
</div>
@@ -245,7 +245,7 @@ describe('AccountSettings', () => {
expect(hookMocks.exportAccount).toHaveBeenCalledOnce();
expect(createObjectUrlSpy).toHaveBeenCalledOnce();
expect(anchorClickSpy).toHaveBeenCalledOnce();
expect(createdAnchor?.download).toBe('Account 1.json');
expect(createdAnchor?.download).toBe('Account_1.json');
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:test-account');
});
@@ -52,6 +52,11 @@ const rememberImportedAccountAddress = (address: string) => {
}
};
const getSafeAccountBackupFileName = (accountName: string | undefined): string => {
const safeName = (accountName || 'account').replace(/[^\w.-]/g, '_') || 'account';
return `${safeName}.json`;
};
// Inner component keyed by account id so state resets when user switches account
const AccountSettingsEditor = ({
account,
@@ -98,7 +103,7 @@ const AccountSettingsEditor = ({
const fileUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = fileUrl;
link.download = `${account?.name ?? 'account'}.json`;
link.download = getSafeAccountBackupFileName(account?.name);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -120,7 +125,7 @@ const AccountSettingsEditor = ({
const reader = new FileReader();
reader.onload = async (e) => {
const fileContent = e.target!.result;
const fileContent = e.target?.result ?? reader.result;
if (typeof fileContent !== 'string') {
alert('File content is not a string.');
return;
@@ -23,6 +23,7 @@ const SettingsModal = () => {
const location = useLocation();
const navigate = useNavigate();
const hash = location.hash.slice(1);
const hashSection = hashToSection(hash);
const closeModal = useCallback(() => {
const newPath = location.pathname.replace(/\/settings$/, '');
@@ -43,30 +44,29 @@ const SettingsModal = () => {
}, [closeModal]);
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => {
const section = hashToSection(hash);
return section ? new Set([section]) : new Set();
return hashSection ? new Set([hashSection]) : new Set();
});
const showInterfaceSettings = expandedSections.has('interface-settings');
const showMediaHostingSettings = expandedSections.has('media-hosting-settings');
const showAccountSettings = expandedSections.has('account-settings');
const showSubscriptionsSettings = expandedSections.has('subscriptions-settings');
const showAdvancedSettings = expandedSections.has('advanced-settings');
const visibleExpandedSections = useMemo(() => {
if (!hashSection || expandedSections.has(hashSection)) return expandedSections;
const nextSections = new Set(expandedSections);
nextSections.add(hashSection);
return nextSections;
}, [expandedSections, hashSection]);
const allExpanded = useMemo(() => allSectionIds.every((id) => expandedSections.has(id)), [expandedSections]);
const showInterfaceSettings = visibleExpandedSections.has('interface-settings');
const showMediaHostingSettings = visibleExpandedSections.has('media-hosting-settings');
const showAccountSettings = visibleExpandedSections.has('account-settings');
const showSubscriptionsSettings = visibleExpandedSections.has('subscriptions-settings');
const showAdvancedSettings = visibleExpandedSections.has('advanced-settings');
const allExpanded = useMemo(() => allSectionIds.every((id) => visibleExpandedSections.has(id)), [visibleExpandedSections]);
const basePath = location.pathname;
useEffect(() => {
const section = hashToSection(hash);
if (section && !expandedSections.has(section)) {
setExpandedSections((prev) => new Set(prev).add(section));
}
}, [hash]); // eslint-disable-line react-hooks/exhaustive-deps
const handleCategoryClick = (categoryId: string) => {
const isOpening = !expandedSections.has(categoryId);
const next = new Set(expandedSections);
const isOpening = !visibleExpandedSections.has(categoryId);
const next = new Set(visibleExpandedSections);
if (isOpening) {
next.add(categoryId);
} else {
@@ -104,10 +104,20 @@ const SettingsModal = () => {
return (
<>
<div className={styles.overlay} role='button' tabIndex={0} onClick={closeModal} onKeyDown={handleKeyDown(closeModal)} />
<div className={styles.settingsModal}>
<div className={styles.settingsModal} role='dialog' aria-modal='true' aria-labelledby='settings-modal-title'>
<div className={styles.header}>
<span className={styles.title}>{t('settings')}</span>
<span className={styles.closeButton} role='button' tabIndex={0} title='close' onClick={closeModal} onKeyDown={handleKeyDown(closeModal)} />
<span id='settings-modal-title' className={styles.title}>
{t('settings')}
</span>
<span
className={styles.closeButton}
role='button'
tabIndex={0}
title='close'
aria-label={t('close')}
onClick={closeModal}
onKeyDown={handleKeyDown(closeModal)}
/>
</div>
<div className={styles.expandAllSettings}>
[
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
import { useCurrentTime } from '../hooks/use-current-time';
import { getFormattedTimeAgo } from '../lib/utils/time-utils';
import Tooltip from './tooltip';
const TimeAgoTooltipContent = ({ timestamp }: { timestamp?: number }) => {
useCurrentTime();
return <>{timestamp === undefined || Number.isNaN(timestamp) ? '' : getFormattedTimeAgo(timestamp)}</>;
};
const TimeAgoTooltip = ({ timestamp, children }: { timestamp?: number; children: ReactNode }) => (
<Tooltip content={<TimeAgoTooltipContent timestamp={timestamp} />}>{children}</Tooltip>
);
export default TimeAgoTooltip;
+1 -1
View File
@@ -3,7 +3,7 @@ import { useFloating, autoUpdate, offset, shift, useHover, useFocus, useDismiss,
import styles from './tooltip.module.css';
interface TooltipProps {
content: string;
content: ReactNode;
children: ReactNode;
showTooltip?: boolean;
}
+16 -4
View File
@@ -47,29 +47,41 @@ describe('browser hooks', () => {
vi.useRealTimers();
});
it('tracks the window width across resize events', () => {
it('tracks the window width across resize events', async () => {
expect(renderHookValue(() => useWindowWidth())).toBe(1024);
act(() => {
await act(async () => {
window.innerWidth = 480;
window.dispatchEvent(new Event('resize'));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
expect(latestValue).toBe(480);
});
it('derives the mobile breakpoint from the current window width', () => {
it('derives the mobile breakpoint from the current window width', async () => {
renderHookValue(() => useIsMobile());
expect(latestValue).toBe(false);
act(() => {
await act(async () => {
window.innerWidth = 639;
window.dispatchEvent(new Event('resize'));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
expect(latestValue).toBe(true);
});
it('reads the current width when remounted after a resize with no subscribers', () => {
expect(renderHookValue(() => useWindowWidth())).toBe(1024);
act(() => root.unmount());
window.innerWidth = 480;
root = createRoot(container);
expect(renderHookValue(() => useWindowWidth())).toBe(480);
});
it('updates the current time on the configured interval', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
+1 -1
View File
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Comment } from '@bitsocial/bitsocial-react-hooks';
import { flattenCommentsPages } from '@bitsocial/bitsocial-react-hooks/dist/lib/utils';
const useCountLinksInReplies = (comment: Comment, firstXReplies?: number) => {
const useCountLinksInReplies = (comment: Comment | undefined, firstXReplies?: number) => {
let linkCount = 0;
const flattenedReplies = useMemo(() => flattenCommentsPages(comment?.replies), [comment?.replies]);
+4 -1
View File
@@ -18,7 +18,10 @@ export const useCurrentTime = (updateIntervalSeconds: number | false = 60) => {
// Update periodically
const intervalId = setInterval(() => {
setCurrentTime(Date.now() / 1000);
setCurrentTime((previousTime) => {
const nextTime = Date.now() / 1000;
return Math.floor(nextTime) === Math.floor(previousTime) ? previousTime : nextTime;
});
}, updateIntervalSeconds * 1000);
return () => clearInterval(intervalId);
+2 -3
View File
@@ -1,8 +1,7 @@
import useWindowWidth from './use-window-width';
import { useIsMobileBreakpoint } from './use-window-width';
const useIsMobile = () => {
const windowWidth = useWindowWidth();
return windowWidth < 640;
return useIsMobileBreakpoint();
};
export default useIsMobile;
+11 -8
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Comment, usePublishComment } from '@bitsocial/bitsocial-react-hooks';
import { useShallow } from 'zustand/react/shallow';
import usePublishPostStore from '../stores/use-publish-post-store';
import useChallengesStore from '../stores/use-challenges-store';
import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from './use-publish-author-domain-guard';
@@ -9,14 +10,16 @@ type UsePublishPostOptions = {
};
const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
const { author, title, content, link, spoiler, publishCommentOptions } = usePublishPostStore((state) => ({
author: state.author,
title: state.title || undefined,
content: state.content || undefined,
link: state.link || undefined,
spoiler: state.spoiler || false,
publishCommentOptions: state.publishCommentOptions,
}));
const { author, title, content, link, spoiler, publishCommentOptions } = usePublishPostStore(
useShallow((state) => ({
author: state.author,
title: state.title || undefined,
content: state.content || undefined,
link: state.link || undefined,
spoiler: state.spoiler || false,
publishCommentOptions: state.publishCommentOptions,
})),
);
const setPublishPostStore = usePublishPostStore((state) => state.setPublishPostStore);
const resetPublishPostStore = usePublishPostStore((state) => state.resetPublishPostStore);
+10 -7
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Comment, useAccount, usePublishComment } from '@bitsocial/bitsocial-react-hooks';
import { useShallow } from 'zustand/react/shallow';
import { useDirectories } from './use-directories';
import usePublishReplyStore from '../stores/use-publish-reply-store';
import usePostNumberStore, { getScopedNumberToCidMap } from '../stores/use-post-number-store';
@@ -22,13 +23,15 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
const account = useAccount();
const directories = useDirectories();
const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore((state) => ({
author: state.author[parentCid],
content: state.content[parentCid] || undefined,
link: state.link[parentCid] || undefined,
spoiler: state.spoiler[parentCid] || false,
publishCommentOptions: state.publishCommentOptions[parentCid],
}));
const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore(
useShallow((state) => ({
author: state.author[parentCid],
content: state.content[parentCid] || undefined,
link: state.link[parentCid] || undefined,
spoiler: state.spoiler[parentCid] || false,
publishCommentOptions: state.publishCommentOptions[parentCid],
})),
);
const setPublishReplyStore = usePublishReplyStore((state) => state.setPublishReplyStore);
const resetPublishReplyStore = usePublishReplyStore((state) => state.resetPublishReplyStore);
+1 -1
View File
@@ -47,7 +47,7 @@ const sanitizeSingleFeedLoadingState = (stateString?: string): string | undefine
.replace(/\bloading thread\b/g, 'loading board');
};
const useStateString = (commentOrCommunity: CommentOrCommunity): string | undefined => {
const useStateString = (commentOrCommunity: CommentOrCommunity | undefined): string | undefined => {
const { states: rawStates } = useClientsStates({ comment: commentOrCommunity }) as { states: States };
const debouncedStates = useMemo(() => {
+54 -11
View File
@@ -1,18 +1,61 @@
import { useState, useEffect } from 'react';
import { useSyncExternalStore } from 'react';
const useWindowWidth = () => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const MOBILE_BREAKPOINT_WIDTH = 640;
const SERVER_WIDTH = 1024;
useEffect(() => {
function handleResize() {
setWindowWidth(window.innerWidth);
}
type Listener = () => void;
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const listeners = new Set<Listener>();
let windowWidth = typeof window === 'undefined' ? SERVER_WIDTH : window.innerWidth;
let animationFrameId: number | null = null;
return windowWidth;
const readWindowWidth = () => (typeof window === 'undefined' ? SERVER_WIDTH : window.innerWidth);
const emitIfChanged = () => {
const nextWindowWidth = readWindowWidth();
if (nextWindowWidth === windowWidth) return;
windowWidth = nextWindowWidth;
listeners.forEach((listener) => listener());
};
const handleResize = () => {
if (typeof window === 'undefined' || animationFrameId !== null) return;
animationFrameId = window.requestAnimationFrame(() => {
animationFrameId = null;
emitIfChanged();
});
};
const subscribe = (listener: Listener) => {
listeners.add(listener);
if (typeof window !== 'undefined' && listeners.size === 1) {
windowWidth = readWindowWidth();
window.addEventListener('resize', handleResize, { passive: true });
}
return () => {
listeners.delete(listener);
if (typeof window !== 'undefined' && listeners.size === 0) {
window.removeEventListener('resize', handleResize);
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
}
};
};
const getWindowWidthSnapshot = () => windowWidth;
const getServerWindowWidthSnapshot = () => SERVER_WIDTH;
const getIsMobileSnapshot = () => windowWidth < MOBILE_BREAKPOINT_WIDTH;
const getServerIsMobileSnapshot = () => SERVER_WIDTH < MOBILE_BREAKPOINT_WIDTH;
const useWindowWidth = () => useSyncExternalStore(subscribe, getWindowWidthSnapshot, getServerWindowWidthSnapshot);
export const useIsMobileBreakpoint = () => useSyncExternalStore(subscribe, getIsMobileSnapshot, getServerIsMobileSnapshot);
export default useWindowWidth;
+8 -2
View File
@@ -1,3 +1,5 @@
import { isPrivateNetworkHostname } from './utils/url-utils';
const DEFAULT_RELEASE_API_URL = 'https://api.github.com/repos/bitsocialnet/5chan/releases/latest';
const DEFAULT_RELEASES_BASE_URL = 'https://github.com/bitsocialnet/5chan/releases/tag/';
@@ -23,11 +25,15 @@ const isAllowedDownloadUrl = (url: string): boolean => {
const parsedUrl = new URL(url);
const hostname = parsedUrl.hostname.toLowerCase();
if (parsedUrl.protocol === 'https:' && hostname === 'github.com') {
if (parsedUrl.protocol === 'https:' && hostname === 'github.com' && parsedUrl.pathname.startsWith('/bitsocialnet/5chan/releases/download/')) {
return true;
}
return configuredDownloadHosts.has(hostname) && (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:');
if (!configuredDownloadHosts.has(hostname)) {
return false;
}
return parsedUrl.protocol === 'https:' || (parsedUrl.protocol === 'http:' && isPrivateNetworkHostname(hostname));
} catch {
return false;
}
+39 -1
View File
@@ -160,6 +160,20 @@ describe('media-utils', () => {
type: 'image',
url: 'https://example.com/file.png',
});
expect(getCommentMediaInfo('https://example.com/file.png', 'http://127.0.0.1/thumb.png', 320, 240)).toEqual({
linkHeight: 240,
linkWidth: 320,
thumbnail: undefined,
type: 'image',
url: 'https://example.com/file.png',
});
expect(getCommentMediaInfo('https://example.com/post', '//192.168.1.1/thumb.png', 320, 240)).toEqual({
linkHeight: 240,
linkWidth: 320,
thumbnail: undefined,
type: 'webpage',
url: 'https://example.com/post',
});
expect(getCommentMediaInfo('https://x.com/post/123', 'https://example.com/thumb.png', 100, 50)).toEqual({
linkHeight: 50,
linkWidth: 100,
@@ -220,7 +234,7 @@ describe('media-utils', () => {
url: 'https://example.com/og-page',
});
expect(testState.fetchMock).toHaveBeenCalledWith('https://example.com/og-page', expect.objectContaining({ headers: { Accept: 'text/html' } }));
expect(testState.fetchMock).toHaveBeenCalledWith('https://example.com/og-page', expect.objectContaining({ headers: { Accept: 'text/html' }, redirect: 'manual' }));
expect(testState.localForageSetItemMock).toHaveBeenCalledWith('https://example.com/og-page', 'https://cdn.example/og.png');
expect(result).toEqual({
thumbnail: 'https://cdn.example/og.png',
@@ -229,6 +243,29 @@ describe('media-utils', () => {
});
});
it('falls back to the first image when og:image is not allowed', async () => {
testState.fetchMock.mockResolvedValue(
createFetchResponse(`
<html>
<head><meta property="og:image" content="http://127.0.0.1/og.png" /></head>
<body><img src="https://cdn.example/fallback.png" /></body>
</html>
`),
);
const result = await fetchWebpageThumbnailIfNeeded({
type: 'webpage',
url: 'https://example.com/fallback-page',
});
expect(testState.localForageSetItemMock).toHaveBeenCalledWith('https://example.com/fallback-page', 'https://cdn.example/fallback.png');
expect(result).toEqual({
thumbnail: 'https://cdn.example/fallback.png',
type: 'webpage',
url: 'https://example.com/fallback-page',
});
});
it('fetches first-image thumbnails on native and resolves relative urls', async () => {
testState.isNativePlatform = true;
testState.capacitorHttpGetMock.mockResolvedValue({
@@ -247,6 +284,7 @@ describe('media-utils', () => {
expect(testState.capacitorHttpGetMock).toHaveBeenCalledWith(
expect.objectContaining({
connectTimeout: 5000,
disableRedirects: true,
headers: { Accept: 'text/html', Range: 'bytes=0-1048575' },
readTimeout: 5000,
responseType: 'text',
+19
View File
@@ -12,6 +12,7 @@ import {
copyShareLinkToClipboard,
getHostname,
is5chanLink,
isPrivateNetworkHostname,
isValidCrossboardPattern,
isValidPublishURL,
isValidURL,
@@ -28,9 +29,27 @@ describe('url-utils', () => {
expect(getHostname('https://www.5chan.app/#/music.eth')).toBe('5chan.app');
expect(getHostname('not-a-url')).toBe('');
expect(isValidURL('https://5chan.app')).toBe(true);
expect(isValidURL('http://5chan.app')).toBe(true);
expect(isValidURL('javascript:alert(1)')).toBe(false);
expect(isValidURL('data:text/html,hello')).toBe(false);
expect(isValidURL('file:///tmp/pic.png')).toBe(false);
expect(isValidURL('not-a-url')).toBe(false);
});
it('detects private network hostnames used by URL safety checks', () => {
expect(isPrivateNetworkHostname('localhost')).toBe(true);
expect(isPrivateNetworkHostname('branch.localhost')).toBe(true);
expect(isPrivateNetworkHostname('127.0.0.1')).toBe(true);
expect(isPrivateNetworkHostname('192.168.1.1')).toBe(true);
expect(isPrivateNetworkHostname('[::1]')).toBe(true);
expect(isPrivateNetworkHostname('[::ffff:7f00:1]')).toBe(true);
expect(isPrivateNetworkHostname('fc00::1')).toBe(true);
expect(isPrivateNetworkHostname('fd12:3456:789a::1')).toBe(true);
expect(isPrivateNetworkHostname('fcbarcelona.com')).toBe(false);
expect(isPrivateNetworkHostname('fdic.gov')).toBe(false);
expect(isPrivateNetworkHostname('example.com')).toBe(false);
});
it('normalizes publish links to the https URLs accepted by communities', () => {
expect(normalizePublishURL(' http://i.imgur.com/YpB7qfa.jpg ')).toBe('https://i.imgur.com/YpB7qfa.jpg');
expect(normalizePublishURL('https://i.imgur.com/YpB7qfa.jpg')).toBe('https://i.imgur.com/YpB7qfa.jpg');
+18 -5
View File
@@ -1,4 +1,4 @@
import { ChallengeVerification } from '@bitsocial/bitsocial-react-hooks';
import type { ChallengeVerification, Comment } from '@bitsocial/bitsocial-react-hooks';
import { getFallbackDirectoriesData } from '../../hooks/use-directories';
import { getCommentCommunityAddress } from './comment-utils';
import { getBoardPath } from './route-utils';
@@ -12,7 +12,20 @@ const resolveBoardIdentifier = (communityAddress: unknown): string => {
return boardPath === communityAddress ? communityAddress : `/${boardPath}/`;
};
export const alertChallengeVerificationFailed = (challengeVerification: ChallengeVerification, publication: any) => {
export type ChallengePublication = Partial<Comment> & {
author?: unknown;
commentCid?: string;
communityAddress?: string;
content?: string;
link?: string;
parentCid?: string;
shortCommunityAddress?: string;
subplebbitAddress?: string;
title?: string;
vote?: number;
};
export const alertChallengeVerificationFailed = (challengeVerification: ChallengeVerification, publication: ChallengePublication | undefined) => {
if (challengeVerification?.challengeSuccess === false) {
console.warn('Challenge Verification Failed:', challengeVerification, 'Publication:', publication);
@@ -44,7 +57,7 @@ export const alertChallengeVerificationFailed = (challengeVerification: Challeng
}
};
export const getPublicationType = (publication: any) => {
export const getPublicationType = (publication: ChallengePublication | undefined) => {
if (!publication) {
return;
}
@@ -60,7 +73,7 @@ export const getPublicationType = (publication: any) => {
return 'post';
};
export const getVotePreview = (publication: any) => {
export const getVotePreview = (publication: ChallengePublication | undefined) => {
if (typeof publication?.vote !== 'number') {
return '';
}
@@ -73,7 +86,7 @@ export const getVotePreview = (publication: any) => {
return votePreview;
};
export const getPublicationPreview = (publication: any) => {
export const getPublicationPreview = (publication: ChallengePublication | undefined) => {
if (!publication) {
return '';
}
+2 -2
View File
@@ -3,9 +3,9 @@
*/
export const copyToClipboard = async (text: string): Promise<void> => {
// Check if we're in Electron and use its clipboard API
if (typeof window !== 'undefined' && (window as any).electronApi?.copyToClipboard) {
if (typeof window !== 'undefined' && window.electronApi?.copyToClipboard) {
try {
const result = await (window as any).electronApi.copyToClipboard(text);
const result = await window.electronApi.copyToClipboard(text);
if (!result.success) {
throw new Error(result.error || 'Failed to copy to clipboard');
}
+18 -16
View File
@@ -4,7 +4,7 @@ type CommentWithCommunityAddress = {
pages?: Record<
string,
| {
comments?: Array<CommentWithCommunityAddress | undefined>;
comments?: unknown[];
}
| undefined
>;
@@ -24,15 +24,16 @@ export const getCommentCommunityAddress = (comment?: unknown) => {
return undefined;
};
const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']) => {
if (!replies?.pages) {
const withResolvedReplyPages = <T>(replies: T): T => {
const replyCollection = replies as CommentWithCommunityAddress['replies'];
if (!replyCollection?.pages) {
return replies;
}
let nextPages = replies.pages;
let nextPages = replyCollection.pages;
let pagesChanged = false;
for (const [sortType, page] of Object.entries(replies.pages)) {
for (const [sortType, page] of Object.entries(replyCollection.pages)) {
if (!page?.comments?.length) {
continue;
}
@@ -58,7 +59,7 @@ const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']
}
if (!pagesChanged) {
nextPages = { ...replies.pages };
nextPages = { ...replyCollection.pages };
pagesChanged = true;
}
@@ -73,27 +74,28 @@ const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']
}
return {
...replies,
...replyCollection,
pages: nextPages,
};
} as T;
};
export const withResolvedCommentCommunityAddress = <T extends CommentWithCommunityAddress | undefined | null>(comment: T): T => {
if (!comment) {
export const withResolvedCommentCommunityAddress = <T>(comment: T): T => {
if (!comment || typeof comment !== 'object') {
return comment;
}
const communityAddress = getCommentCommunityAddress(comment);
const replies = withResolvedReplyPages(comment.replies);
const needsResolvedCommunityAddress = !!communityAddress && comment.communityAddress !== communityAddress;
const commentRecord = comment as CommentWithCommunityAddress;
const communityAddress = getCommentCommunityAddress(commentRecord);
const replies = withResolvedReplyPages(commentRecord.replies);
const needsResolvedCommunityAddress = !!communityAddress && commentRecord.communityAddress !== communityAddress;
if (!needsResolvedCommunityAddress && replies === comment.replies) {
if (!needsResolvedCommunityAddress && replies === commentRecord.replies) {
return comment;
}
return {
...comment,
...commentRecord,
...(needsResolvedCommunityAddress ? { communityAddress } : {}),
...(replies !== comment.replies ? { replies } : {}),
...(replies !== commentRecord.replies ? { replies } : {}),
} as T;
};
+42 -12
View File
@@ -1,7 +1,7 @@
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { canEmbed } from '../../components/embed';
import memoize from 'memoizee';
import { isValidURL } from './url-utils';
import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils';
import { Capacitor, CapacitorHttp } from '@capacitor/core';
export interface CommentMediaInfo {
@@ -15,7 +15,9 @@ export interface CommentMediaInfo {
linkHeight?: number;
}
export const getDisplayMediaInfoType = (type: string, t: any) => {
type Translate = (key: string) => string;
export const getDisplayMediaInfoType = (type: string, t: Translate) => {
switch (type) {
case 'image':
return t('image');
@@ -95,6 +97,23 @@ const isThumbnailDomainBlacklisted = (link: string | undefined): boolean => {
}
};
const parseAllowedThumbnailFetchUrl = (value: string): URL | undefined => {
const parsedUrl = parseHttpUrl(value);
if (!parsedUrl || parsedUrl.protocol !== 'https:' || isPrivateNetworkHostname(parsedUrl.hostname)) {
return undefined;
}
return parsedUrl;
};
const getAllowedThumbnailUrl = (value: string, baseUrl: string): string | undefined => {
try {
const parsedUrl = new URL(value, baseUrl);
return parsedUrl.protocol === 'https:' && !isPrivateNetworkHostname(parsedUrl.hostname) ? parsedUrl.href : undefined;
} catch {
return undefined;
}
};
export const getLinkMediaInfo = memoize(
(link: string): CommentMediaInfo | undefined => {
if (!isValidURL(link)) {
@@ -148,6 +167,9 @@ export const getLinkMediaInfo = memoize(
const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> => {
try {
const parsedUrl = parseAllowedThumbnailFetchUrl(url);
if (!parsedUrl) return undefined;
let html: string;
const MAX_HTML_SIZE = 1024 * 1024;
const TIMEOUT = 5000;
@@ -155,10 +177,11 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
if (Capacitor.isNativePlatform()) {
// in the native app, the Capacitor HTTP plugin is used to fetch the thumbnail
const response = await CapacitorHttp.get({
url,
url: parsedUrl.href,
readTimeout: TIMEOUT,
connectTimeout: TIMEOUT,
responseType: 'text',
disableRedirects: true,
headers: { Accept: 'text/html', Range: `bytes=0-${MAX_HTML_SIZE - 1}` },
});
html = response.data.slice(0, MAX_HTML_SIZE);
@@ -167,8 +190,9 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
const response = await fetch(url, {
const response = await fetch(parsedUrl.href, {
signal: controller.signal,
redirect: 'manual',
headers: { Accept: 'text/html' },
});
@@ -177,9 +201,10 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
if (!response.ok) throw new Error('Network response was not ok');
const reader = response.body?.getReader();
if (!reader) return undefined;
let result = '';
while (true) {
const { done, value } = await reader!.read();
const { done, value } = await reader.read();
if (done || result.length >= MAX_HTML_SIZE) break;
result += new TextDecoder().decode(value);
}
@@ -191,14 +216,17 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
// Try to find Open Graph image
const ogImage = doc.querySelector('meta[property="og:image"]');
if (ogImage && ogImage.getAttribute('content')) {
return ogImage.getAttribute('content')!;
const ogImageContent = ogImage?.getAttribute('content');
if (ogImageContent) {
const ogImageUrl = getAllowedThumbnailUrl(ogImageContent, parsedUrl.href);
if (ogImageUrl) return ogImageUrl;
}
// If no Open Graph image, try to find the first image
const firstImage = doc.querySelector('img');
if (firstImage && firstImage.getAttribute('src')) {
return new URL(firstImage.getAttribute('src')!, url).href;
const firstImageSrc = firstImage?.getAttribute('src');
if (firstImageSrc) {
return getAllowedThumbnailUrl(firstImageSrc, parsedUrl.href);
}
return undefined;
@@ -214,6 +242,7 @@ export const getCommentMediaInfo = (link: string, thumbnailUrl: string, linkWidt
}
const linkInfo = link ? getLinkMediaInfo(link) : undefined;
if (linkInfo) {
const safeThumbnailUrl = thumbnailUrl ? getAllowedThumbnailUrl(thumbnailUrl, linkInfo.url) : undefined;
// Don't show thumbnails for blacklisted domains (e.g., Twitter/X) as they return non-thumbnail images like emojis
if (isThumbnailDomainBlacklisted(link)) {
return {
@@ -226,7 +255,7 @@ export const getCommentMediaInfo = (link: string, thumbnailUrl: string, linkWidt
}
return {
...linkInfo,
thumbnail: thumbnailUrl || linkInfo.thumbnail,
thumbnail: safeThumbnailUrl || linkInfo.thumbnail,
linkWidth,
linkHeight,
};
@@ -287,8 +316,9 @@ const setCachedThumbnail = async (url: string, thumbnail: string): Promise<void>
export const fetchWebpageThumbnailIfNeeded = async (commentMediaInfo: CommentMediaInfo): Promise<CommentMediaInfo> => {
if (commentMediaInfo.type === 'webpage' && !commentMediaInfo.thumbnail) {
const cachedThumbnail = await getCachedThumbnail(commentMediaInfo.url);
if (cachedThumbnail) {
return { ...commentMediaInfo, thumbnail: cachedThumbnail };
const safeCachedThumbnail = cachedThumbnail ? getAllowedThumbnailUrl(cachedThumbnail, commentMediaInfo.url) : undefined;
if (safeCachedThumbnail) {
return { ...commentMediaInfo, thumbnail: safeCachedThumbnail };
}
const thumbnail = await fetchWebpageThumbnail(commentMediaInfo.url);
if (thumbnail) {
+40 -20
View File
@@ -6,6 +6,37 @@ type CommunityLike = {
roles?: Record<string, { role?: string }>;
};
const compiledRegexCache = new Map<string, RegExp>();
const commentTextCache = new WeakMap<Comment, string>();
const MAX_COMPILED_REGEX_CACHE_SIZE = 500;
const getCompiledRegex = (pattern: string, flags = ''): RegExp => {
const cacheKey = `${pattern}\u0000${flags}`;
const cachedRegex = compiledRegexCache.get(cacheKey);
if (cachedRegex) return cachedRegex;
const regex = new RegExp(pattern, flags);
if (compiledRegexCache.size >= MAX_COMPILED_REGEX_CACHE_SIZE) {
compiledRegexCache.clear();
}
compiledRegexCache.set(cacheKey, regex);
return regex;
};
const testRegex = (regex: RegExp, text: string): boolean => {
regex.lastIndex = 0;
return regex.test(text);
};
const getCommentSearchText = (comment: Comment): string => {
const cachedText = commentTextCache.get(comment);
if (cachedText !== undefined) return cachedText;
const searchText = `${comment?.title?.toLowerCase() || ''} ${comment?.content?.toLowerCase() || ''}`;
commentTextCache.set(comment, searchText);
return searchText;
};
/**
* Checks if a text matches a pattern according to various pattern matching rules:
* - Whole word matching: 'feel' matches 'feel' but not 'feeling'
@@ -31,8 +62,7 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
const lastSlashIndex = pattern.lastIndexOf('/');
const regexPattern = pattern.substring(1, lastSlashIndex);
const flags = pattern.substring(lastSlashIndex + 1);
const regex = new RegExp(regexPattern, flags);
return regex.test(textLower);
return testRegex(getCompiledRegex(regexPattern, flags), textLower);
}
// Check if it's an exact match pattern (surrounded by quotes)
else if (pattern.startsWith('"') && pattern.endsWith('"') && pattern.length > 2) {
@@ -50,12 +80,10 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
// Handle wildcards in OR terms
if (term.includes('*')) {
const regexPattern = term.replace(/\*/g, '.*').toLowerCase();
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
} else {
// Match whole word only
const regex = new RegExp(`\\b${term}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${term}\\b`, 'i'), textLower);
}
});
});
@@ -70,25 +98,21 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
// Handle wildcards in AND terms
if (term.includes('*')) {
const regexPattern = term.replace(/\*/g, '.*').toLowerCase();
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
} else {
// Match whole word only
const regex = new RegExp(`\\b${term}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${term}\\b`, 'i'), textLower);
}
});
}
// Handle wildcard patterns
else if (pattern.includes('*')) {
const regexPattern = pattern.replace(/\*/g, '.*').toLowerCase();
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
}
// Simple whole word match
else {
const regex = new RegExp(`\\b${pattern.toLowerCase()}\\b`, 'i');
return regex.test(textLower);
return testRegex(getCompiledRegex(`\\b${pattern.toLowerCase()}\\b`, 'i'), textLower);
}
} catch (error) {
// If regex parsing fails, fall back to simple includes
@@ -233,7 +257,7 @@ export const commentMatchesPattern = (comment: Comment, pattern: string): boolea
// If there's also a content filter, check if the comment matches it as well
if (contentFilter) {
return allSpecialFiltersMatch && matchesPattern((comment?.title || '') + ' ' + (comment?.content || ''), contentFilter);
return allSpecialFiltersMatch && matchesPattern(getCommentSearchText(comment), contentFilter);
}
return allSpecialFiltersMatch;
@@ -260,9 +284,5 @@ export const commentMatchesPattern = (comment: Comment, pattern: string): boolea
}
// Regular content matching
const titleLower = comment?.title?.toLowerCase() || '';
const contentLower = comment?.content?.toLowerCase() || '';
const textToMatch = titleLower + ' ' + contentLower;
return matchesPattern(textToMatch, pattern);
return matchesPattern(getCommentSearchText(comment), pattern);
};
+24 -7
View File
@@ -1,11 +1,12 @@
import i18next from 'i18next';
export const getFormattedDate = (commentTimestamp: number) => {
if (commentTimestamp === undefined || isNaN(commentTimestamp)) {
return '';
}
const locale = i18next.language || 'en';
const string = new Intl.DateTimeFormat(locale, {
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
const getDateTimeFormatter = (locale: string) => {
const cachedFormatter = dateTimeFormatters.get(locale);
if (cachedFormatter) return cachedFormatter;
const formatter = new Intl.DateTimeFormat(locale, {
hour12: false,
year: '2-digit',
month: '2-digit',
@@ -14,7 +15,23 @@ export const getFormattedDate = (commentTimestamp: number) => {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(commentTimestamp * 1000));
});
dateTimeFormatters.set(locale, formatter);
return formatter;
};
if (typeof i18next.on === 'function') {
i18next.on('languageChanged', () => {
dateTimeFormatters.clear();
});
}
export const getFormattedDate = (commentTimestamp: number) => {
if (commentTimestamp === undefined || isNaN(commentTimestamp)) {
return '';
}
const locale = i18next.language || 'en';
const string = getDateTimeFormatter(locale).format(new Date(commentTimestamp * 1000));
if (locale.startsWith('ar')) {
return string;
}
+34 -2
View File
@@ -11,12 +11,44 @@ export const getHostname = (url: string) => {
};
export const isValidURL = (url: string) => {
return parseHttpUrl(url) !== null;
};
export const parseHttpUrl = (url: string): URL | null => {
try {
new URL(url);
return true;
const parsedUrl = new URL(url);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl : null;
} catch {
return null;
}
};
export const isPrivateNetworkHostname = (hostname: string): boolean => {
const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (
normalizedHostname === 'localhost' ||
normalizedHostname.endsWith('.localhost') ||
normalizedHostname.endsWith('.local') ||
normalizedHostname === '0.0.0.0' ||
normalizedHostname === '::1' ||
normalizedHostname === '::' ||
normalizedHostname.startsWith('::ffff:')
) {
return true;
}
const ipv4Parts = normalizedHostname.split('.').map((part) => Number(part));
if (ipv4Parts.length === 4 && ipv4Parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [first, second] = ipv4Parts;
return first === 10 || first === 127 || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168);
}
if (!normalizedHostname.includes(':')) {
return false;
}
return normalizedHostname.startsWith('fc') || normalizedHostname.startsWith('fd') || normalizedHostname.startsWith('fe80:');
};
export const normalizePublishURL = (url: string) => {
-4
View File
@@ -3,8 +3,4 @@ declare module '*.module.css' {
export default classes;
}
declare module 'lodash';
declare module 'react-draggable';
declare module 'react-router-hash-link';
+2 -10
View File
@@ -16,14 +16,6 @@ if (!window.fetch) {
console.warn('Fetch API is not available, using polyfill');
}
// For crypto support
if (typeof window.crypto === 'undefined') {
window.crypto = {};
}
if (typeof window.crypto.getRandomValues === 'undefined') {
window.crypto.getRandomValues = function (array) {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
};
if (typeof window.crypto === 'undefined' || typeof window.crypto.getRandomValues !== 'function') {
throw new Error('crypto.getRandomValues is required for secure account and signature operations.');
}
@@ -87,7 +87,7 @@ describe('useCatalogFiltersStore', () => {
expect(persisted).not.toContain('"filteredCids"');
});
it('applies search and content filters, hides matching comments, and counts hidden cids only once per board', async () => {
it('applies search and content filters without mutating counts from the predicate', async () => {
const useCatalogFiltersStore = await loadStore();
useCatalogFiltersStore.getState().setFilterItems([createFilterItem('spam', { hide: true }), createFilterItem('highlight', { hide: false, top: true })] as never);
@@ -103,15 +103,13 @@ describe('useCatalogFiltersStore', () => {
expect(filter?.({ cid: 'cid-3', content: 'topic highlight', communityAddress: 'music.eth' } as never)).toBe(true);
expect(filter?.({ cid: 'cid-4', content: 'ordinary update', communityAddress: 'music.eth' } as never)).toBe(false);
vi.runAllTimers();
const state = useCatalogFiltersStore.getState();
expect(testState.commentMatchesPatternMock).toHaveBeenCalled();
expect(state.filterItems[0].count).toBe(1);
expect(state.filterItems[0].filteredCids).toEqual(new Set(['cid-1']));
expect(state.filterItems[0].communityCounts.get('music.eth')).toBe(1);
expect(state.filteredCount).toBe(1);
expect(state.getFilteredCountForCurrentCommunity()).toBe(1);
expect(state.filterItems[0].count).toBe(0);
expect(state.filterItems[0].filteredCids).toEqual(new Set());
expect(state.filterItems[0].communityCounts.get('music.eth')).toBe(0);
expect(state.filteredCount).toBe(0);
expect(state.getFilteredCountForCurrentCommunity()).toBe(0);
});
it('preserves counts for unchanged filters when saving and clears matched filters', async () => {
+23 -60
View File
@@ -1,7 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
import { commentMatchesPattern } from '../lib/utils/pattern-utils';
interface FilterItem {
@@ -72,6 +71,25 @@ const normalizeFilterItem = (item: RawFilterItem): FilterItem => {
};
};
const createCatalogFilter = (get: () => CatalogFiltersStore) => (comment: Comment) => {
if (!comment?.cid) return true;
const state = get();
if (state.searchText.trim() !== '' && !commentMatchesPattern(comment, state.searchText)) {
return false;
}
let shouldHide = false;
for (const item of state.filterItems) {
if (item.enabled && item.text.trim() !== '' && item.hide && commentMatchesPattern(comment, item.text)) {
shouldHide = true;
break;
}
}
return !shouldHide;
};
const useCatalogFiltersStore = create(
persist<CatalogFiltersStore>(
(set, get) => ({
@@ -138,9 +156,7 @@ const useCatalogFiltersStore = create(
};
});
// Recalculate the filtered count for the current community
get().recalcFilteredCount();
get().updateFilter();
} else {
set({ currentCommunityAddress: address });
}
@@ -148,11 +164,9 @@ const useCatalogFiltersStore = create(
searchText: '',
setSearchFilter: (text: string) => {
set({ searchText: text });
get().updateFilter();
},
clearSearchFilter: () => {
set({ searchText: '' });
get().updateFilter();
},
setFilterItems: (items: FilterItem[]) => {
const nonEmptyItems = items.filter((item) => item.text.trim() !== '').map((item) => normalizeFilterItem(item));
@@ -200,57 +214,10 @@ const useCatalogFiltersStore = create(
});
get().recalcFilteredCount();
get().updateFilter();
},
filter: undefined,
updateFilter: () => {
set((state) => ({
filter: (comment: Comment) => {
if (!comment?.cid) return true;
const currentCommunityAddress = state.currentCommunityAddress;
// Apply search filter
if (state.searchText.trim() !== '') {
if (!commentMatchesPattern(comment, state.searchText)) {
return false;
}
}
// Apply content filters
const { filterItems } = state;
let shouldHide = false;
const commentCommunityAddress = getCommentCommunityAddress(comment);
for (let i = 0; i < filterItems.length; i++) {
const item = filterItems[i];
if (item.enabled && item.text.trim() !== '') {
if (commentMatchesPattern(comment, item.text)) {
// If we have a current community and this is a match, increment the count
if (currentCommunityAddress && commentCommunityAddress && commentCommunityAddress === currentCommunityAddress) {
// We need to use a timeout to avoid modifying state during a state update
setTimeout(() => {
const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled);
if (filterIndex !== -1) {
get().incrementFilterCount(filterIndex, comment.cid, commentCommunityAddress);
}
}, 0);
}
if (item.hide) {
shouldHide = true;
}
}
}
}
return !shouldHide;
},
}));
},
initializeFilter: () => {
get().updateFilter();
},
filter: createCatalogFilter(get),
updateFilter: () => undefined,
initializeFilter: () => undefined,
incrementFilterCount: (filterIndex: number, cid: string, communityAddress: string) => {
set((state) => {
const newFilterItems = [...state.filterItems];
@@ -351,9 +318,6 @@ const useCatalogFiltersStore = create(
filteredCount: 0,
};
});
// Trigger filter reapplication to start counting again
get().updateFilter();
},
}),
{
@@ -365,6 +329,7 @@ const useCatalogFiltersStore = create(
enabled: item.enabled,
hide: item.hide,
top: item.top,
color: item.color,
})),
} as any;
},
@@ -395,6 +360,4 @@ const useCatalogFiltersStore = create(
),
);
useCatalogFiltersStore.getState().updateFilter();
export default useCatalogFiltersStore;
+8
View File
@@ -108,6 +108,14 @@ const useModQueueStore = create<ModQueueState>()(
};
return current as ModQueueState;
},
partialize: (state): PersistedModQueueData => ({
alertThresholdValue: state.alertThresholdValue,
alertThresholdUnit: state.alertThresholdUnit,
dismissedCommentCids: state.dismissedCommentCids,
queuedCommentHistory: state.queuedCommentHistory,
selectedBoardFilter: state.selectedBoardFilter,
viewMode: state.viewMode,
}),
},
),
);
+5 -3
View File
@@ -233,6 +233,7 @@ vi.mock('../../../hooks/use-state-string', () => ({
vi.mock('../../../hooks/use-window-width', () => ({
default: () => testState.windowWidth,
useIsMobileBreakpoint: () => testState.windowWidth < 640,
}));
vi.mock('../../../stores/use-catalog-style-store', () => ({
@@ -250,9 +251,10 @@ vi.mock('../../../stores/use-feed-reset-store', () => ({
}));
vi.mock('../../../stores/use-sorting-store', () => ({
default: () => ({
sortType: testState.sortType,
}),
default: (selector?: (state: { sortType: typeof testState.sortType }) => unknown) => {
const state = { sortType: testState.sortType };
return selector ? selector(state) : state;
},
}));
vi.mock('../../../stores/use-catalog-filters-store', () => ({
+5 -4
View File
@@ -272,7 +272,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return resolvedAddressFromUrl;
}, [boardIdentifierProp, directories, resolvedAddressFromUrl]);
const { filterItems, searchText } = useCatalogFiltersStore();
const filterItems = useCatalogFiltersStore((state) => state.filterItems);
const searchText = useCatalogFiltersStore((state) => state.searchText);
const account = useAccount();
const subscriptions = account?.subscriptions;
@@ -306,7 +307,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const communities = useCommunityIdentifiers(communityAddresses);
const communityIdentifier = useCommunityIdentifier(communityAddress);
const { imageSize, showOPComment } = useCatalogStyleStore();
const imageSize = useCatalogStyleStore((state) => state.imageSize);
const showOPComment = useCatalogStyleStore((state) => state.showOPComment);
const columnWidth = imageSize === 'Large' ? 270 : 180;
const windowWidth = useWindowWidth();
const isMobile = useIsMobile();
@@ -325,7 +327,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
}
}, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, location.search, navigate]);
const { sortType } = useSortingStore();
const sortType = useSortingStore((state) => state.sortType);
const feedSortType = sortType === 'new' ? 'new' : 'active';
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
@@ -632,7 +634,6 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
effectiveInfiniteScroll,
],
);
const isFeedLoaded = feed.length > 0 || state === 'failed';
// Process the feed to move "top" posts to the top (applied after display sort)
@@ -66,7 +66,8 @@ const PopularThreadCard = memo(
const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: DirectoryCommunity[]; directoryAddresses: string[] }) => {
const { t } = useTranslation();
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
const showWorksafeContentOnly = usePopularThreadsOptionsStore((state) => state.showWorksafeContentOnly);
const showNsfwContentOnly = usePopularThreadsOptionsStore((state) => state.showNsfwContentOnly);
const filteredBoardAddresses = useMemo(() => {
return directoryAddresses.flatMap((address) => {
+105 -94
View File
@@ -2,6 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocial/bitsocial-react-hooks';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import { Virtuoso } from 'react-virtuoso';
import styles from './mod-queue.module.css';
import useModQueueStore from '../../stores/use-mod-queue-store';
@@ -47,6 +48,62 @@ const getBoardDisplayPath = (address: string, path: string): string => {
return getShortAddress(address) || address;
};
type LocalModerationEditSummary = Record<string, { timestamp?: number; value: unknown } | undefined>;
type LocalModerationEditSummaries = Record<string, LocalModerationEditSummary | undefined>;
const LOCAL_MODERATION_EDIT_FIELDS = ['approved', 'removed'] as const;
const LOCAL_EDIT_PENDING_SECONDS = 20 * 60;
const shouldApplyLocalModerationEdit = (
comment: Comment,
propertyName: (typeof LOCAL_MODERATION_EDIT_FIELDS)[number],
edit: { timestamp?: number; value: unknown },
now: number,
) => {
const editTimestamp = edit.timestamp ?? 0;
const updatedAt = (comment as { updatedAt?: number }).updatedAt;
const currentValue = (comment as Record<string, unknown>)[propertyName];
if (!updatedAt) {
return Object.is(currentValue, edit.value) || editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS;
}
if (updatedAt < editTimestamp || Object.is(currentValue, edit.value)) {
return true;
}
return editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS || updatedAt - editTimestamp < LOCAL_EDIT_PENDING_SECONDS;
};
const applyLocalModerationEdits = (comment: Comment, editSummary: LocalModerationEditSummary | undefined, now: number): Comment => {
if (!editSummary) {
return comment;
}
let editedComment: Comment | undefined;
for (const propertyName of LOCAL_MODERATION_EDIT_FIELDS) {
const edit = editSummary[propertyName];
if (!edit || edit.value === undefined || !shouldApplyLocalModerationEdit(comment, propertyName, edit, now)) {
continue;
}
editedComment = { ...(editedComment ?? comment), [propertyName]: edit.value } as Comment;
}
return editedComment ?? comment;
};
const useLocallyModeratedModQueueFeed = (feed: Comment[], currentTime: number) => {
const accountId = useAccountsStore((state) => state.activeAccountId);
const editSummaries = useAccountsStore((state) => (accountId ? state.accountsEditsSummaries[accountId] : undefined)) as LocalModerationEditSummaries | undefined;
return useMemo(() => {
if (!editSummaries) {
return feed;
}
return feed.map((comment) => (comment.cid ? applyLocalModerationEdits(comment, editSummaries[comment.cid], currentTime) : comment));
}, [currentTime, editSummaries, feed]);
};
interface ModQueueViewProps {
boardIdentifier?: string; // If provided, shows queue for single board
}
@@ -305,7 +362,7 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath, boardDisplayPath }: ModQueueRowProps) => {
const { t } = useTranslation();
const { getAlertThresholdSeconds } = useModQueueStore();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const isMobile = useIsMobile();
const currentTime = useCurrentTime();
@@ -319,7 +376,7 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
const isOverThreshold = timeWaiting > alertThresholdSeconds;
// Only show alert animation for comments awaiting approval (not approved or rejected)
const isAwaitingApproval = isPendingApprovalAwaiting(comment);
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
const hasTitle = title && title.trim().length > 0;
@@ -413,7 +470,7 @@ interface ModQueueCardProps {
const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplayPath }: ModQueueCardProps) => {
const { t } = useTranslation();
const { getAlertThresholdSeconds } = useModQueueStore();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const currentTime = useCurrentTime();
const { editedComment } = useEditedComment({ comment });
@@ -424,7 +481,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
const timeWaiting = currentTime - timestamp;
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = timeWaiting > alertThresholdSeconds;
const isAwaitingApproval = isPendingApprovalAwaiting(comment);
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
const hasTitle = title && title.trim().length > 0;
@@ -533,17 +590,20 @@ const findBoardAddressByCode = (code: string, dirs: DirectoryCommunity[]): strin
const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: ModQueueBoardSummaryProps) => {
const { t } = useTranslation();
const { selectedBoardFilter, setSelectedBoardFilter, getAlertThresholdSeconds } = useModQueueStore();
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
const setSelectedBoardFilter = useModQueueStore((state) => state.setSelectedBoardFilter);
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const currentTime = useCurrentTime();
const alertThresholdSeconds = getAlertThresholdSeconds();
const modAddressSet = useMemo(() => new Set(accountCommunityAddresses), [accountCommunityAddresses]);
const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime);
const boardCounts = useMemo(() => {
const counts = new Map<string, { normal: number; urgent: number }>();
for (const address of accountCommunityAddresses) {
counts.set(address, { normal: 0, urgent: 0 });
}
for (const item of feed) {
for (const item of locallyModeratedFeed) {
const addr = getCommentCommunityAddress(item);
if (!addr) continue;
const entry = counts.get(addr);
@@ -556,7 +616,7 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }:
else entry.normal++;
}
return counts;
}, [feed, accountCommunityAddresses, currentTime, alertThresholdSeconds]);
}, [locallyModeratedFeed, accountCommunityAddresses, currentTime, alertThresholdSeconds]);
const { totalNormal, totalUrgent } = useMemo(() => {
let normal = 0;
@@ -663,29 +723,6 @@ interface ModQueueButtonProps {
isMobile?: boolean;
}
interface ModQueueCountItemProps {
comment: Comment;
alertThresholdSeconds: number;
onStatusChange: (cid: string, status: { awaiting: boolean; urgent: boolean }) => void;
}
const ModQueueCountItem = ({ comment, alertThresholdSeconds, onStatusChange }: ModQueueCountItemProps) => {
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const currentTime = useCurrentTime();
const { cid, timestamp } = displayComment;
const isAwaiting = isPendingApprovalAwaiting(displayComment);
const timeWaiting = currentTime - timestamp;
const isUrgent = isAwaiting && timeWaiting > alertThresholdSeconds;
useEffect(() => {
onStatusChange(cid, { awaiting: isAwaiting, urgent: isUrgent });
}, [cid, isAwaiting, isUrgent, onStatusChange]);
return null;
};
interface ModQueueButtonContentProps {
feed: Comment[];
alertThresholdSeconds: number;
@@ -695,84 +732,54 @@ interface ModQueueButtonContentProps {
const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
const { t } = useTranslation();
const [statusMap, setStatusMap] = useState<Map<string, { awaiting: boolean; urgent: boolean }>>(new Map());
const handleStatusChange = React.useCallback((cid: string, status: { awaiting: boolean; urgent: boolean }) => {
setStatusMap((prev) => {
const next = new Map(prev);
next.set(cid, status);
return next;
});
}, []);
// Clean up stale entries when comments leave the feed to prevent memory leaks
const feedCids = useMemo(() => new Set(feed.map((item) => item.cid)), [feed]);
useEffect(() => {
setStatusMap((prev) => {
const staleKeys = [...prev.keys()].filter((cid) => !feedCids.has(cid));
if (staleKeys.length === 0) return prev;
const next = new Map(prev);
for (const key of staleKeys) {
next.delete(key);
}
return next;
});
}, [feedCids]);
const currentTime = useCurrentTime();
const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime);
const { normalCount, urgentCount } = useMemo(() => {
let normal = 0;
let urgent = 0;
for (const { awaiting, urgent: isUrgent } of statusMap.values()) {
if (awaiting) {
if (isUrgent) urgent++;
else normal++;
}
for (const comment of locallyModeratedFeed) {
if (!isPendingApprovalAwaiting(comment)) continue;
const timeWaiting = currentTime - (comment.timestamp ?? 0);
if (timeWaiting > alertThresholdSeconds) urgent++;
else normal++;
}
return { normalCount: normal, urgentCount: urgent };
}, [statusMap]);
}, [alertThresholdSeconds, currentTime, locallyModeratedFeed]);
const totalCount = normalCount + urgentCount;
const to = boardIdentifier ? `/${boardIdentifier}/mod/queue` : '/mod/queue';
const buttonContent = (
<button className='button'>
<Link to={to}>
{t('mod_queue')}
{totalCount > 0 && (
<strong>
(
{urgentCount > 0 && normalCount > 0 ? (
<>
<span className={styles.modQueueButtonCount}>{normalCount}</span>
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>
{'+'}
{urgentCount}
</span>
</>
) : urgentCount > 0 ? (
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgentCount}</span>
) : (
<span className={styles.modQueueButtonCount}>{totalCount}</span>
)}
)
</strong>
)}
</Link>
</button>
<Link className='button' to={to}>
{t('mod_queue')}
{totalCount > 0 && (
<strong>
(
{urgentCount > 0 && normalCount > 0 ? (
<>
<span className={styles.modQueueButtonCount}>{normalCount}</span>
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>
{'+'}
{urgentCount}
</span>
</>
) : urgentCount > 0 ? (
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgentCount}</span>
) : (
<span className={styles.modQueueButtonCount}>{totalCount}</span>
)}
)
</strong>
)}
</Link>
);
return (
<>
{feed.map((item) => (
<ModQueueCountItem key={item.cid} comment={item} alertThresholdSeconds={alertThresholdSeconds} onStatusChange={handleStatusChange} />
))}
{isMobile ? buttonContent : <>[{buttonContent}]</>}
</>
);
return isMobile ? buttonContent : <>[{buttonContent}]</>;
};
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
const { getAlertThresholdSeconds } = useModQueueStore();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const account = useAccount();
const accountAddress = account?.author?.address;
@@ -834,7 +841,7 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
}
const alertThresholdSeconds = getAlertThresholdSeconds();
// Use key to reset statusMap state when switching boards (prevents stale counts from previous board)
// Remount when switching boards so memoized counts reset cleanly.
const contentKey = communityAddresses.join(',');
return <ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
};
@@ -842,7 +849,11 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
const { t } = useTranslation();
const params = useParams();
const { selectedBoardFilter, viewMode, dismissedCommentCids, queuedCommentHistory, rememberCommentsInQueue } = useModQueueStore();
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
const viewMode = useModQueueStore((state) => state.viewMode);
const dismissedCommentCids = useModQueueStore((state) => state.dismissedCommentCids);
const queuedCommentHistory = useModQueueStore((state) => state.queuedCommentHistory);
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
const isMobile = useIsMobile();
const accountCommunityAddresses = useAccountCommunityAddresses();
+9 -7
View File
@@ -23,11 +23,16 @@ import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
import styles from './post.module.css';
type CommentWithRefresh = Comment & {
export type CommentWithRefresh = Comment & {
approved?: boolean;
communityAddress?: string;
refresh?: () => Promise<void>;
state?: string;
pendingApproval?: boolean;
error?: Error;
errors?: Error[];
index?: number;
removed?: boolean;
};
const getRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
@@ -134,9 +139,9 @@ export interface PostProps {
index?: number;
isHidden?: boolean;
hasThumbnail?: boolean;
post?: any;
post?: CommentWithRefresh;
postReplyCount?: number;
reply?: any;
reply?: Comment;
replyPaginationOverride?: ReplyPaginationOverride;
replyVirtualizationModeOverride?: ReplyVirtualizationMode;
roles?: Role[];
@@ -364,10 +369,7 @@ const PostPage = () => {
const queuedReplyHasMore = queuedReplyRepliesResult.hasMore;
const queuedReplyLoadMore = queuedReplyRepliesResult.loadMore;
const queuedReplyReset = (queuedReplyRepliesResult as { reset?: () => Promise<void> }).reset;
const queuedReplyReplies =
((queuedReplyRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (queuedReplyRepliesResult as { updatedReplies?: Comment[] }).updatedReplies
: queuedReplyRepliesResult.replies) || [];
const queuedReplyReplies = (queuedReplyRepliesResult.updatedReplies?.length ? queuedReplyRepliesResult.updatedReplies : queuedReplyRepliesResult.replies) || [];
const replyPaginationOverride = useMemo(() => {
if (!queuedReply || !post?.cid) return undefined;
return {