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
@@ -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;
}