mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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(() =>
|
||||
|
||||
@@ -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 "Accept," you confirm that:</p>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.mobileFooterButtons a {
|
||||
.mobileFooterButtons a:not([class~='button']) {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user