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:
+22
-31
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
import { useAccount, useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { initSnow, removeSnow } from './lib/snow';
|
||||
@@ -31,9 +32,7 @@ import {
|
||||
} from './lib/utils/route-utils';
|
||||
import styles from './app.module.css';
|
||||
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons';
|
||||
import Board from './views/board';
|
||||
import Blotter from './views/blotter';
|
||||
import Catalog from './views/catalog';
|
||||
import FAQ from './views/faq';
|
||||
import Home from './views/home';
|
||||
import Archive from './views/archive/archive';
|
||||
@@ -180,7 +179,27 @@ const BoardLayout = () => {
|
||||
const GlobalLayout = () => {
|
||||
useTheme();
|
||||
|
||||
const { activeCid, parentNumber, threadNumber, threadCid, communityAddress: activeCommunityAddress, closeModal, showReplyModal, scrollY } = useReplyModalStore();
|
||||
const {
|
||||
activeCid,
|
||||
parentNumber,
|
||||
threadNumber,
|
||||
threadCid,
|
||||
communityAddress: activeCommunityAddress,
|
||||
closeModal,
|
||||
showReplyModal,
|
||||
scrollY,
|
||||
} = useReplyModalStore(
|
||||
useShallow((state) => ({
|
||||
activeCid: state.activeCid,
|
||||
parentNumber: state.parentNumber,
|
||||
threadNumber: state.threadNumber,
|
||||
threadCid: state.threadCid,
|
||||
communityAddress: state.communityAddress,
|
||||
closeModal: state.closeModal,
|
||||
showReplyModal: state.showReplyModal,
|
||||
scrollY: state.scrollY,
|
||||
})),
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
const isInSettingsView = location.pathname.endsWith('/settings');
|
||||
@@ -215,34 +234,6 @@ const GlobalLayout = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Wraps Board with viewType/boardIdentifier derived from current route. Used when infinite scroll is OFF. */
|
||||
const BoardFeedRoute = () => {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const viewType: 'all' | 'subs' | 'mod' | 'board' = isAllView(location.pathname)
|
||||
? 'all'
|
||||
: isSubscriptionsView(location.pathname, params)
|
||||
? 'subs'
|
||||
: isModView(location.pathname)
|
||||
? 'mod'
|
||||
: 'board';
|
||||
return <Board viewType={viewType} boardIdentifier={params.boardIdentifier} />;
|
||||
};
|
||||
|
||||
/** Wraps Catalog with viewType/boardIdentifier derived from current route. Used when infinite scroll is OFF. */
|
||||
const CatalogFeedRoute = () => {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const viewType: 'all' | 'subs' | 'mod' | 'board' = isAllView(location.pathname)
|
||||
? 'all'
|
||||
: isSubscriptionsView(location.pathname, params)
|
||||
? 'subs'
|
||||
: isModView(location.pathname)
|
||||
? 'mod'
|
||||
: 'board';
|
||||
return <Catalog viewType={viewType} boardIdentifier={params.boardIdentifier} />;
|
||||
};
|
||||
|
||||
const ModQueueRoute = () => {
|
||||
const { boardIdentifier } = useParams();
|
||||
const account = useAccount();
|
||||
|
||||
@@ -209,6 +209,8 @@ const clickButton = async (text: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const findButtonLink = (text: string) => Array.from(container.querySelectorAll<HTMLAnchorElement>('a.button')).find((candidate) => candidate.textContent === text);
|
||||
|
||||
const changeSelect = async (select: HTMLSelectElement, value: string) => {
|
||||
await act(async () => {
|
||||
select.value = value;
|
||||
@@ -282,6 +284,7 @@ describe('BoardButtons', () => {
|
||||
expect(container.querySelector('[data-testid="mod-queue-button"]')?.textContent).toBe('mu');
|
||||
expect(container.textContent).toContain('subscribe');
|
||||
expect(container.textContent).toContain('vote');
|
||||
expect(findButtonLink('catalog')?.getAttribute('href')).toBe('/mu/catalog');
|
||||
|
||||
const searchInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(searchInput).toBeTruthy();
|
||||
@@ -317,6 +320,7 @@ describe('BoardButtons', () => {
|
||||
expect(container.textContent).not.toContain('archive');
|
||||
expect(container.querySelector('[data-testid="catalog-filters"]')?.textContent).toBe('catalog-filters');
|
||||
expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search');
|
||||
expect(findButtonLink('return')?.getAttribute('href')).toBe('/all?t=24h');
|
||||
|
||||
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
|
||||
expect(selects).toHaveLength(5);
|
||||
@@ -429,8 +433,7 @@ describe('BoardButtons', () => {
|
||||
|
||||
await renderWithRoute(createElement(MobileBoardButtons), '/mod/queue');
|
||||
|
||||
const returnLink = container.querySelector<HTMLAnchorElement>('a[href="/mod"]');
|
||||
expect(returnLink?.getAttribute('href')).toBe('/mod');
|
||||
expect(findButtonLink('return')?.getAttribute('href')).toBe('/mod');
|
||||
|
||||
const thresholdInput = container.querySelector<HTMLInputElement>('input[type="number"]');
|
||||
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.mobileBoardButtons button {
|
||||
.mobileBoardButtons button,
|
||||
.mobileBoardButtons a[class~='button'] {
|
||||
text-transform: capitalize;
|
||||
margin: 5px 2px;
|
||||
}
|
||||
@@ -33,7 +34,7 @@
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.mobileBoardButtons a, .desktopBoardButtons a {
|
||||
.mobileBoardButtons a:not([class~='button']), .desktopBoardButtons a:not([class~='button']) {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
@@ -147,4 +148,4 @@
|
||||
.mobileBoardButtons {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,9 @@ export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isI
|
||||
};
|
||||
|
||||
return (
|
||||
<button className='button'>
|
||||
<Link to={createCatalogLink()}>{t('catalog')}</Link>
|
||||
</button>
|
||||
<Link className='button' to={createCatalogLink()}>
|
||||
{t('catalog')}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -151,9 +151,9 @@ export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isIn
|
||||
};
|
||||
|
||||
return (
|
||||
<button className='button'>
|
||||
<Link to={createReturnLink()}>{t('return')}</Link>
|
||||
</button>
|
||||
<Link className='button' to={createReturnLink()}>
|
||||
{t('return')}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -125,9 +125,9 @@ const BoardsBarEditModal = () => {
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.boardsbarEditDialog}>
|
||||
<div className={styles.boardsbarEditDialog} role='dialog' aria-modal='true' aria-labelledby='boards-bar-edit-modal-title'>
|
||||
<div className={styles.hd}>
|
||||
<h2>Custom Board List</h2>
|
||||
<h2 id='boards-bar-edit-modal-title'>Custom Board List</h2>
|
||||
<button type='button' className={styles.closeButton} onClick={closeBoardsBarEditModal} title='Close' aria-label='Close' />
|
||||
</div>
|
||||
<div className={styles.bd}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import FiltersProtip from './filters-protip';
|
||||
@@ -47,11 +48,13 @@ const toCatalogFilterItem = (item: CatalogFilterItemInput): CatalogFilterItemSto
|
||||
|
||||
const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentCommunityAddress, filterItems, saveAndApplyFilters } = useCatalogFiltersStore((state) => ({
|
||||
currentCommunityAddress: state.currentCommunityAddress,
|
||||
filterItems: state.filterItems as CatalogFilterItemInput[],
|
||||
saveAndApplyFilters: state.saveAndApplyFilters,
|
||||
}));
|
||||
const { currentCommunityAddress, filterItems, saveAndApplyFilters } = useCatalogFiltersStore(
|
||||
useShallow((state) => ({
|
||||
currentCommunityAddress: state.currentCommunityAddress,
|
||||
filterItems: state.filterItems as CatalogFilterItemInput[],
|
||||
saveAndApplyFilters: state.saveAndApplyFilters,
|
||||
})),
|
||||
);
|
||||
const resetFeed = useFeedResetStore((state) => state.reset);
|
||||
|
||||
const [localFilterItems, setLocalFilterItems] = useState(() =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -47,29 +47,41 @@ describe('browser hooks', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('tracks the window width across resize events', () => {
|
||||
it('tracks the window width across resize events', async () => {
|
||||
expect(renderHookValue(() => useWindowWidth())).toBe(1024);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
window.innerWidth = 480;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
|
||||
expect(latestValue).toBe(480);
|
||||
});
|
||||
|
||||
it('derives the mobile breakpoint from the current window width', () => {
|
||||
it('derives the mobile breakpoint from the current window width', async () => {
|
||||
renderHookValue(() => useIsMobile());
|
||||
expect(latestValue).toBe(false);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
window.innerWidth = 639;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
|
||||
expect(latestValue).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the current width when remounted after a resize with no subscribers', () => {
|
||||
expect(renderHookValue(() => useWindowWidth())).toBe(1024);
|
||||
|
||||
act(() => root.unmount());
|
||||
window.innerWidth = 480;
|
||||
root = createRoot(container);
|
||||
|
||||
expect(renderHookValue(() => useWindowWidth())).toBe(480);
|
||||
});
|
||||
|
||||
it('updates the current time on the configured interval', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { Comment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { flattenCommentsPages } from '@bitsocial/bitsocial-react-hooks/dist/lib/utils';
|
||||
|
||||
const useCountLinksInReplies = (comment: Comment, firstXReplies?: number) => {
|
||||
const useCountLinksInReplies = (comment: Comment | undefined, firstXReplies?: number) => {
|
||||
let linkCount = 0;
|
||||
const flattenedReplies = useMemo(() => flattenCommentsPages(comment?.replies), [comment?.replies]);
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ export const useCurrentTime = (updateIntervalSeconds: number | false = 60) => {
|
||||
|
||||
// Update periodically
|
||||
const intervalId = setInterval(() => {
|
||||
setCurrentTime(Date.now() / 1000);
|
||||
setCurrentTime((previousTime) => {
|
||||
const nextTime = Date.now() / 1000;
|
||||
return Math.floor(nextTime) === Math.floor(previousTime) ? previousTime : nextTime;
|
||||
});
|
||||
}, updateIntervalSeconds * 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import useWindowWidth from './use-window-width';
|
||||
import { useIsMobileBreakpoint } from './use-window-width';
|
||||
|
||||
const useIsMobile = () => {
|
||||
const windowWidth = useWindowWidth();
|
||||
return windowWidth < 640;
|
||||
return useIsMobileBreakpoint();
|
||||
};
|
||||
|
||||
export default useIsMobile;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Comment, usePublishComment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import usePublishPostStore from '../stores/use-publish-post-store';
|
||||
import useChallengesStore from '../stores/use-challenges-store';
|
||||
import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from './use-publish-author-domain-guard';
|
||||
@@ -9,14 +10,16 @@ type UsePublishPostOptions = {
|
||||
};
|
||||
|
||||
const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
|
||||
const { author, title, content, link, spoiler, publishCommentOptions } = usePublishPostStore((state) => ({
|
||||
author: state.author,
|
||||
title: state.title || undefined,
|
||||
content: state.content || undefined,
|
||||
link: state.link || undefined,
|
||||
spoiler: state.spoiler || false,
|
||||
publishCommentOptions: state.publishCommentOptions,
|
||||
}));
|
||||
const { author, title, content, link, spoiler, publishCommentOptions } = usePublishPostStore(
|
||||
useShallow((state) => ({
|
||||
author: state.author,
|
||||
title: state.title || undefined,
|
||||
content: state.content || undefined,
|
||||
link: state.link || undefined,
|
||||
spoiler: state.spoiler || false,
|
||||
publishCommentOptions: state.publishCommentOptions,
|
||||
})),
|
||||
);
|
||||
|
||||
const setPublishPostStore = usePublishPostStore((state) => state.setPublishPostStore);
|
||||
const resetPublishPostStore = usePublishPostStore((state) => state.resetPublishPostStore);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Comment, useAccount, usePublishComment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useDirectories } from './use-directories';
|
||||
import usePublishReplyStore from '../stores/use-publish-reply-store';
|
||||
import usePostNumberStore, { getScopedNumberToCidMap } from '../stores/use-post-number-store';
|
||||
@@ -22,13 +23,15 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
|
||||
const account = useAccount();
|
||||
const directories = useDirectories();
|
||||
|
||||
const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore((state) => ({
|
||||
author: state.author[parentCid],
|
||||
content: state.content[parentCid] || undefined,
|
||||
link: state.link[parentCid] || undefined,
|
||||
spoiler: state.spoiler[parentCid] || false,
|
||||
publishCommentOptions: state.publishCommentOptions[parentCid],
|
||||
}));
|
||||
const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore(
|
||||
useShallow((state) => ({
|
||||
author: state.author[parentCid],
|
||||
content: state.content[parentCid] || undefined,
|
||||
link: state.link[parentCid] || undefined,
|
||||
spoiler: state.spoiler[parentCid] || false,
|
||||
publishCommentOptions: state.publishCommentOptions[parentCid],
|
||||
})),
|
||||
);
|
||||
|
||||
const setPublishReplyStore = usePublishReplyStore((state) => state.setPublishReplyStore);
|
||||
const resetPublishReplyStore = usePublishReplyStore((state) => state.resetPublishReplyStore);
|
||||
|
||||
@@ -47,7 +47,7 @@ const sanitizeSingleFeedLoadingState = (stateString?: string): string | undefine
|
||||
.replace(/\bloading thread\b/g, 'loading board');
|
||||
};
|
||||
|
||||
const useStateString = (commentOrCommunity: CommentOrCommunity): string | undefined => {
|
||||
const useStateString = (commentOrCommunity: CommentOrCommunity | undefined): string | undefined => {
|
||||
const { states: rawStates } = useClientsStates({ comment: commentOrCommunity }) as { states: States };
|
||||
|
||||
const debouncedStates = useMemo(() => {
|
||||
|
||||
@@ -1,18 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const useWindowWidth = () => {
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
const MOBILE_BREAKPOINT_WIDTH = 640;
|
||||
const SERVER_WIDTH = 1024;
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowWidth(window.innerWidth);
|
||||
}
|
||||
type Listener = () => void;
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
const listeners = new Set<Listener>();
|
||||
let windowWidth = typeof window === 'undefined' ? SERVER_WIDTH : window.innerWidth;
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
return windowWidth;
|
||||
const readWindowWidth = () => (typeof window === 'undefined' ? SERVER_WIDTH : window.innerWidth);
|
||||
|
||||
const emitIfChanged = () => {
|
||||
const nextWindowWidth = readWindowWidth();
|
||||
if (nextWindowWidth === windowWidth) return;
|
||||
|
||||
windowWidth = nextWindowWidth;
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (typeof window === 'undefined' || animationFrameId !== null) return;
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
animationFrameId = null;
|
||||
emitIfChanged();
|
||||
});
|
||||
};
|
||||
|
||||
const subscribe = (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
|
||||
if (typeof window !== 'undefined' && listeners.size === 1) {
|
||||
windowWidth = readWindowWidth();
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
|
||||
if (typeof window !== 'undefined' && listeners.size === 0) {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getWindowWidthSnapshot = () => windowWidth;
|
||||
const getServerWindowWidthSnapshot = () => SERVER_WIDTH;
|
||||
const getIsMobileSnapshot = () => windowWidth < MOBILE_BREAKPOINT_WIDTH;
|
||||
const getServerIsMobileSnapshot = () => SERVER_WIDTH < MOBILE_BREAKPOINT_WIDTH;
|
||||
|
||||
const useWindowWidth = () => useSyncExternalStore(subscribe, getWindowWidthSnapshot, getServerWindowWidthSnapshot);
|
||||
|
||||
export const useIsMobileBreakpoint = () => useSyncExternalStore(subscribe, getIsMobileSnapshot, getServerIsMobileSnapshot);
|
||||
|
||||
export default useWindowWidth;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isPrivateNetworkHostname } from './utils/url-utils';
|
||||
|
||||
const DEFAULT_RELEASE_API_URL = 'https://api.github.com/repos/bitsocialnet/5chan/releases/latest';
|
||||
const DEFAULT_RELEASES_BASE_URL = 'https://github.com/bitsocialnet/5chan/releases/tag/';
|
||||
|
||||
@@ -23,11 +25,15 @@ const isAllowedDownloadUrl = (url: string): boolean => {
|
||||
const parsedUrl = new URL(url);
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
|
||||
if (parsedUrl.protocol === 'https:' && hostname === 'github.com') {
|
||||
if (parsedUrl.protocol === 'https:' && hostname === 'github.com' && parsedUrl.pathname.startsWith('/bitsocialnet/5chan/releases/download/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return configuredDownloadHosts.has(hostname) && (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:');
|
||||
if (!configuredDownloadHosts.has(hostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsedUrl.protocol === 'https:' || (parsedUrl.protocol === 'http:' && isPrivateNetworkHostname(hostname));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,20 @@ describe('media-utils', () => {
|
||||
type: 'image',
|
||||
url: 'https://example.com/file.png',
|
||||
});
|
||||
expect(getCommentMediaInfo('https://example.com/file.png', 'http://127.0.0.1/thumb.png', 320, 240)).toEqual({
|
||||
linkHeight: 240,
|
||||
linkWidth: 320,
|
||||
thumbnail: undefined,
|
||||
type: 'image',
|
||||
url: 'https://example.com/file.png',
|
||||
});
|
||||
expect(getCommentMediaInfo('https://example.com/post', '//192.168.1.1/thumb.png', 320, 240)).toEqual({
|
||||
linkHeight: 240,
|
||||
linkWidth: 320,
|
||||
thumbnail: undefined,
|
||||
type: 'webpage',
|
||||
url: 'https://example.com/post',
|
||||
});
|
||||
expect(getCommentMediaInfo('https://x.com/post/123', 'https://example.com/thumb.png', 100, 50)).toEqual({
|
||||
linkHeight: 50,
|
||||
linkWidth: 100,
|
||||
@@ -220,7 +234,7 @@ describe('media-utils', () => {
|
||||
url: 'https://example.com/og-page',
|
||||
});
|
||||
|
||||
expect(testState.fetchMock).toHaveBeenCalledWith('https://example.com/og-page', expect.objectContaining({ headers: { Accept: 'text/html' } }));
|
||||
expect(testState.fetchMock).toHaveBeenCalledWith('https://example.com/og-page', expect.objectContaining({ headers: { Accept: 'text/html' }, redirect: 'manual' }));
|
||||
expect(testState.localForageSetItemMock).toHaveBeenCalledWith('https://example.com/og-page', 'https://cdn.example/og.png');
|
||||
expect(result).toEqual({
|
||||
thumbnail: 'https://cdn.example/og.png',
|
||||
@@ -229,6 +243,29 @@ describe('media-utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the first image when og:image is not allowed', async () => {
|
||||
testState.fetchMock.mockResolvedValue(
|
||||
createFetchResponse(`
|
||||
<html>
|
||||
<head><meta property="og:image" content="http://127.0.0.1/og.png" /></head>
|
||||
<body><img src="https://cdn.example/fallback.png" /></body>
|
||||
</html>
|
||||
`),
|
||||
);
|
||||
|
||||
const result = await fetchWebpageThumbnailIfNeeded({
|
||||
type: 'webpage',
|
||||
url: 'https://example.com/fallback-page',
|
||||
});
|
||||
|
||||
expect(testState.localForageSetItemMock).toHaveBeenCalledWith('https://example.com/fallback-page', 'https://cdn.example/fallback.png');
|
||||
expect(result).toEqual({
|
||||
thumbnail: 'https://cdn.example/fallback.png',
|
||||
type: 'webpage',
|
||||
url: 'https://example.com/fallback-page',
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches first-image thumbnails on native and resolves relative urls', async () => {
|
||||
testState.isNativePlatform = true;
|
||||
testState.capacitorHttpGetMock.mockResolvedValue({
|
||||
@@ -247,6 +284,7 @@ describe('media-utils', () => {
|
||||
expect(testState.capacitorHttpGetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectTimeout: 5000,
|
||||
disableRedirects: true,
|
||||
headers: { Accept: 'text/html', Range: 'bytes=0-1048575' },
|
||||
readTimeout: 5000,
|
||||
responseType: 'text',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
copyShareLinkToClipboard,
|
||||
getHostname,
|
||||
is5chanLink,
|
||||
isPrivateNetworkHostname,
|
||||
isValidCrossboardPattern,
|
||||
isValidPublishURL,
|
||||
isValidURL,
|
||||
@@ -28,9 +29,27 @@ describe('url-utils', () => {
|
||||
expect(getHostname('https://www.5chan.app/#/music.eth')).toBe('5chan.app');
|
||||
expect(getHostname('not-a-url')).toBe('');
|
||||
expect(isValidURL('https://5chan.app')).toBe(true);
|
||||
expect(isValidURL('http://5chan.app')).toBe(true);
|
||||
expect(isValidURL('javascript:alert(1)')).toBe(false);
|
||||
expect(isValidURL('data:text/html,hello')).toBe(false);
|
||||
expect(isValidURL('file:///tmp/pic.png')).toBe(false);
|
||||
expect(isValidURL('not-a-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects private network hostnames used by URL safety checks', () => {
|
||||
expect(isPrivateNetworkHostname('localhost')).toBe(true);
|
||||
expect(isPrivateNetworkHostname('branch.localhost')).toBe(true);
|
||||
expect(isPrivateNetworkHostname('127.0.0.1')).toBe(true);
|
||||
expect(isPrivateNetworkHostname('192.168.1.1')).toBe(true);
|
||||
expect(isPrivateNetworkHostname('[::1]')).toBe(true);
|
||||
expect(isPrivateNetworkHostname('[::ffff:7f00:1]')).toBe(true);
|
||||
expect(isPrivateNetworkHostname('fc00::1')).toBe(true);
|
||||
expect(isPrivateNetworkHostname('fd12:3456:789a::1')).toBe(true);
|
||||
expect(isPrivateNetworkHostname('fcbarcelona.com')).toBe(false);
|
||||
expect(isPrivateNetworkHostname('fdic.gov')).toBe(false);
|
||||
expect(isPrivateNetworkHostname('example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes publish links to the https URLs accepted by communities', () => {
|
||||
expect(normalizePublishURL(' http://i.imgur.com/YpB7qfa.jpg ')).toBe('https://i.imgur.com/YpB7qfa.jpg');
|
||||
expect(normalizePublishURL('https://i.imgur.com/YpB7qfa.jpg')).toBe('https://i.imgur.com/YpB7qfa.jpg');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChallengeVerification } from '@bitsocial/bitsocial-react-hooks';
|
||||
import type { ChallengeVerification, Comment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { getFallbackDirectoriesData } from '../../hooks/use-directories';
|
||||
import { getCommentCommunityAddress } from './comment-utils';
|
||||
import { getBoardPath } from './route-utils';
|
||||
@@ -12,7 +12,20 @@ const resolveBoardIdentifier = (communityAddress: unknown): string => {
|
||||
return boardPath === communityAddress ? communityAddress : `/${boardPath}/`;
|
||||
};
|
||||
|
||||
export const alertChallengeVerificationFailed = (challengeVerification: ChallengeVerification, publication: any) => {
|
||||
export type ChallengePublication = Partial<Comment> & {
|
||||
author?: unknown;
|
||||
commentCid?: string;
|
||||
communityAddress?: string;
|
||||
content?: string;
|
||||
link?: string;
|
||||
parentCid?: string;
|
||||
shortCommunityAddress?: string;
|
||||
subplebbitAddress?: string;
|
||||
title?: string;
|
||||
vote?: number;
|
||||
};
|
||||
|
||||
export const alertChallengeVerificationFailed = (challengeVerification: ChallengeVerification, publication: ChallengePublication | undefined) => {
|
||||
if (challengeVerification?.challengeSuccess === false) {
|
||||
console.warn('Challenge Verification Failed:', challengeVerification, 'Publication:', publication);
|
||||
|
||||
@@ -44,7 +57,7 @@ export const alertChallengeVerificationFailed = (challengeVerification: Challeng
|
||||
}
|
||||
};
|
||||
|
||||
export const getPublicationType = (publication: any) => {
|
||||
export const getPublicationType = (publication: ChallengePublication | undefined) => {
|
||||
if (!publication) {
|
||||
return;
|
||||
}
|
||||
@@ -60,7 +73,7 @@ export const getPublicationType = (publication: any) => {
|
||||
return 'post';
|
||||
};
|
||||
|
||||
export const getVotePreview = (publication: any) => {
|
||||
export const getVotePreview = (publication: ChallengePublication | undefined) => {
|
||||
if (typeof publication?.vote !== 'number') {
|
||||
return '';
|
||||
}
|
||||
@@ -73,7 +86,7 @@ export const getVotePreview = (publication: any) => {
|
||||
return votePreview;
|
||||
};
|
||||
|
||||
export const getPublicationPreview = (publication: any) => {
|
||||
export const getPublicationPreview = (publication: ChallengePublication | undefined) => {
|
||||
if (!publication) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
// Check if we're in Electron and use its clipboard API
|
||||
if (typeof window !== 'undefined' && (window as any).electronApi?.copyToClipboard) {
|
||||
if (typeof window !== 'undefined' && window.electronApi?.copyToClipboard) {
|
||||
try {
|
||||
const result = await (window as any).electronApi.copyToClipboard(text);
|
||||
const result = await window.electronApi.copyToClipboard(text);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to copy to clipboard');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ type CommentWithCommunityAddress = {
|
||||
pages?: Record<
|
||||
string,
|
||||
| {
|
||||
comments?: Array<CommentWithCommunityAddress | undefined>;
|
||||
comments?: unknown[];
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
@@ -24,15 +24,16 @@ export const getCommentCommunityAddress = (comment?: unknown) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']) => {
|
||||
if (!replies?.pages) {
|
||||
const withResolvedReplyPages = <T>(replies: T): T => {
|
||||
const replyCollection = replies as CommentWithCommunityAddress['replies'];
|
||||
if (!replyCollection?.pages) {
|
||||
return replies;
|
||||
}
|
||||
|
||||
let nextPages = replies.pages;
|
||||
let nextPages = replyCollection.pages;
|
||||
let pagesChanged = false;
|
||||
|
||||
for (const [sortType, page] of Object.entries(replies.pages)) {
|
||||
for (const [sortType, page] of Object.entries(replyCollection.pages)) {
|
||||
if (!page?.comments?.length) {
|
||||
continue;
|
||||
}
|
||||
@@ -58,7 +59,7 @@ const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']
|
||||
}
|
||||
|
||||
if (!pagesChanged) {
|
||||
nextPages = { ...replies.pages };
|
||||
nextPages = { ...replyCollection.pages };
|
||||
pagesChanged = true;
|
||||
}
|
||||
|
||||
@@ -73,27 +74,28 @@ const withResolvedReplyPages = (replies?: CommentWithCommunityAddress['replies']
|
||||
}
|
||||
|
||||
return {
|
||||
...replies,
|
||||
...replyCollection,
|
||||
pages: nextPages,
|
||||
};
|
||||
} as T;
|
||||
};
|
||||
|
||||
export const withResolvedCommentCommunityAddress = <T extends CommentWithCommunityAddress | undefined | null>(comment: T): T => {
|
||||
if (!comment) {
|
||||
export const withResolvedCommentCommunityAddress = <T>(comment: T): T => {
|
||||
if (!comment || typeof comment !== 'object') {
|
||||
return comment;
|
||||
}
|
||||
|
||||
const communityAddress = getCommentCommunityAddress(comment);
|
||||
const replies = withResolvedReplyPages(comment.replies);
|
||||
const needsResolvedCommunityAddress = !!communityAddress && comment.communityAddress !== communityAddress;
|
||||
const commentRecord = comment as CommentWithCommunityAddress;
|
||||
const communityAddress = getCommentCommunityAddress(commentRecord);
|
||||
const replies = withResolvedReplyPages(commentRecord.replies);
|
||||
const needsResolvedCommunityAddress = !!communityAddress && commentRecord.communityAddress !== communityAddress;
|
||||
|
||||
if (!needsResolvedCommunityAddress && replies === comment.replies) {
|
||||
if (!needsResolvedCommunityAddress && replies === commentRecord.replies) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
return {
|
||||
...comment,
|
||||
...commentRecord,
|
||||
...(needsResolvedCommunityAddress ? { communityAddress } : {}),
|
||||
...(replies !== comment.replies ? { replies } : {}),
|
||||
...(replies !== commentRecord.replies ? { replies } : {}),
|
||||
} as T;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
|
||||
import { canEmbed } from '../../components/embed';
|
||||
import memoize from 'memoizee';
|
||||
import { isValidURL } from './url-utils';
|
||||
import { isPrivateNetworkHostname, isValidURL, parseHttpUrl } from './url-utils';
|
||||
import { Capacitor, CapacitorHttp } from '@capacitor/core';
|
||||
|
||||
export interface CommentMediaInfo {
|
||||
@@ -15,7 +15,9 @@ export interface CommentMediaInfo {
|
||||
linkHeight?: number;
|
||||
}
|
||||
|
||||
export const getDisplayMediaInfoType = (type: string, t: any) => {
|
||||
type Translate = (key: string) => string;
|
||||
|
||||
export const getDisplayMediaInfoType = (type: string, t: Translate) => {
|
||||
switch (type) {
|
||||
case 'image':
|
||||
return t('image');
|
||||
@@ -95,6 +97,23 @@ const isThumbnailDomainBlacklisted = (link: string | undefined): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseAllowedThumbnailFetchUrl = (value: string): URL | undefined => {
|
||||
const parsedUrl = parseHttpUrl(value);
|
||||
if (!parsedUrl || parsedUrl.protocol !== 'https:' || isPrivateNetworkHostname(parsedUrl.hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsedUrl;
|
||||
};
|
||||
|
||||
const getAllowedThumbnailUrl = (value: string, baseUrl: string): string | undefined => {
|
||||
try {
|
||||
const parsedUrl = new URL(value, baseUrl);
|
||||
return parsedUrl.protocol === 'https:' && !isPrivateNetworkHostname(parsedUrl.hostname) ? parsedUrl.href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLinkMediaInfo = memoize(
|
||||
(link: string): CommentMediaInfo | undefined => {
|
||||
if (!isValidURL(link)) {
|
||||
@@ -148,6 +167,9 @@ export const getLinkMediaInfo = memoize(
|
||||
|
||||
const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const parsedUrl = parseAllowedThumbnailFetchUrl(url);
|
||||
if (!parsedUrl) return undefined;
|
||||
|
||||
let html: string;
|
||||
const MAX_HTML_SIZE = 1024 * 1024;
|
||||
const TIMEOUT = 5000;
|
||||
@@ -155,10 +177,11 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// in the native app, the Capacitor HTTP plugin is used to fetch the thumbnail
|
||||
const response = await CapacitorHttp.get({
|
||||
url,
|
||||
url: parsedUrl.href,
|
||||
readTimeout: TIMEOUT,
|
||||
connectTimeout: TIMEOUT,
|
||||
responseType: 'text',
|
||||
disableRedirects: true,
|
||||
headers: { Accept: 'text/html', Range: `bytes=0-${MAX_HTML_SIZE - 1}` },
|
||||
});
|
||||
html = response.data.slice(0, MAX_HTML_SIZE);
|
||||
@@ -167,8 +190,9 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(parsedUrl.href, {
|
||||
signal: controller.signal,
|
||||
redirect: 'manual',
|
||||
headers: { Accept: 'text/html' },
|
||||
});
|
||||
|
||||
@@ -177,9 +201,10 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return undefined;
|
||||
let result = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader!.read();
|
||||
const { done, value } = await reader.read();
|
||||
if (done || result.length >= MAX_HTML_SIZE) break;
|
||||
result += new TextDecoder().decode(value);
|
||||
}
|
||||
@@ -191,14 +216,17 @@ const fetchWebpageThumbnail = async (url: string): Promise<string | undefined> =
|
||||
|
||||
// Try to find Open Graph image
|
||||
const ogImage = doc.querySelector('meta[property="og:image"]');
|
||||
if (ogImage && ogImage.getAttribute('content')) {
|
||||
return ogImage.getAttribute('content')!;
|
||||
const ogImageContent = ogImage?.getAttribute('content');
|
||||
if (ogImageContent) {
|
||||
const ogImageUrl = getAllowedThumbnailUrl(ogImageContent, parsedUrl.href);
|
||||
if (ogImageUrl) return ogImageUrl;
|
||||
}
|
||||
|
||||
// If no Open Graph image, try to find the first image
|
||||
const firstImage = doc.querySelector('img');
|
||||
if (firstImage && firstImage.getAttribute('src')) {
|
||||
return new URL(firstImage.getAttribute('src')!, url).href;
|
||||
const firstImageSrc = firstImage?.getAttribute('src');
|
||||
if (firstImageSrc) {
|
||||
return getAllowedThumbnailUrl(firstImageSrc, parsedUrl.href);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -214,6 +242,7 @@ export const getCommentMediaInfo = (link: string, thumbnailUrl: string, linkWidt
|
||||
}
|
||||
const linkInfo = link ? getLinkMediaInfo(link) : undefined;
|
||||
if (linkInfo) {
|
||||
const safeThumbnailUrl = thumbnailUrl ? getAllowedThumbnailUrl(thumbnailUrl, linkInfo.url) : undefined;
|
||||
// Don't show thumbnails for blacklisted domains (e.g., Twitter/X) as they return non-thumbnail images like emojis
|
||||
if (isThumbnailDomainBlacklisted(link)) {
|
||||
return {
|
||||
@@ -226,7 +255,7 @@ export const getCommentMediaInfo = (link: string, thumbnailUrl: string, linkWidt
|
||||
}
|
||||
return {
|
||||
...linkInfo,
|
||||
thumbnail: thumbnailUrl || linkInfo.thumbnail,
|
||||
thumbnail: safeThumbnailUrl || linkInfo.thumbnail,
|
||||
linkWidth,
|
||||
linkHeight,
|
||||
};
|
||||
@@ -287,8 +316,9 @@ const setCachedThumbnail = async (url: string, thumbnail: string): Promise<void>
|
||||
export const fetchWebpageThumbnailIfNeeded = async (commentMediaInfo: CommentMediaInfo): Promise<CommentMediaInfo> => {
|
||||
if (commentMediaInfo.type === 'webpage' && !commentMediaInfo.thumbnail) {
|
||||
const cachedThumbnail = await getCachedThumbnail(commentMediaInfo.url);
|
||||
if (cachedThumbnail) {
|
||||
return { ...commentMediaInfo, thumbnail: cachedThumbnail };
|
||||
const safeCachedThumbnail = cachedThumbnail ? getAllowedThumbnailUrl(cachedThumbnail, commentMediaInfo.url) : undefined;
|
||||
if (safeCachedThumbnail) {
|
||||
return { ...commentMediaInfo, thumbnail: safeCachedThumbnail };
|
||||
}
|
||||
const thumbnail = await fetchWebpageThumbnail(commentMediaInfo.url);
|
||||
if (thumbnail) {
|
||||
|
||||
@@ -6,6 +6,37 @@ type CommunityLike = {
|
||||
roles?: Record<string, { role?: string }>;
|
||||
};
|
||||
|
||||
const compiledRegexCache = new Map<string, RegExp>();
|
||||
const commentTextCache = new WeakMap<Comment, string>();
|
||||
const MAX_COMPILED_REGEX_CACHE_SIZE = 500;
|
||||
|
||||
const getCompiledRegex = (pattern: string, flags = ''): RegExp => {
|
||||
const cacheKey = `${pattern}\u0000${flags}`;
|
||||
const cachedRegex = compiledRegexCache.get(cacheKey);
|
||||
if (cachedRegex) return cachedRegex;
|
||||
|
||||
const regex = new RegExp(pattern, flags);
|
||||
if (compiledRegexCache.size >= MAX_COMPILED_REGEX_CACHE_SIZE) {
|
||||
compiledRegexCache.clear();
|
||||
}
|
||||
compiledRegexCache.set(cacheKey, regex);
|
||||
return regex;
|
||||
};
|
||||
|
||||
const testRegex = (regex: RegExp, text: string): boolean => {
|
||||
regex.lastIndex = 0;
|
||||
return regex.test(text);
|
||||
};
|
||||
|
||||
const getCommentSearchText = (comment: Comment): string => {
|
||||
const cachedText = commentTextCache.get(comment);
|
||||
if (cachedText !== undefined) return cachedText;
|
||||
|
||||
const searchText = `${comment?.title?.toLowerCase() || ''} ${comment?.content?.toLowerCase() || ''}`;
|
||||
commentTextCache.set(comment, searchText);
|
||||
return searchText;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a text matches a pattern according to various pattern matching rules:
|
||||
* - Whole word matching: 'feel' matches 'feel' but not 'feeling'
|
||||
@@ -31,8 +62,7 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
|
||||
const lastSlashIndex = pattern.lastIndexOf('/');
|
||||
const regexPattern = pattern.substring(1, lastSlashIndex);
|
||||
const flags = pattern.substring(lastSlashIndex + 1);
|
||||
const regex = new RegExp(regexPattern, flags);
|
||||
return regex.test(textLower);
|
||||
return testRegex(getCompiledRegex(regexPattern, flags), textLower);
|
||||
}
|
||||
// Check if it's an exact match pattern (surrounded by quotes)
|
||||
else if (pattern.startsWith('"') && pattern.endsWith('"') && pattern.length > 2) {
|
||||
@@ -50,12 +80,10 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
|
||||
// Handle wildcards in OR terms
|
||||
if (term.includes('*')) {
|
||||
const regexPattern = term.replace(/\*/g, '.*').toLowerCase();
|
||||
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
|
||||
} else {
|
||||
// Match whole word only
|
||||
const regex = new RegExp(`\\b${term}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
return testRegex(getCompiledRegex(`\\b${term}\\b`, 'i'), textLower);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -70,25 +98,21 @@ export const matchesPattern = (text: string, pattern: string): boolean => {
|
||||
// Handle wildcards in AND terms
|
||||
if (term.includes('*')) {
|
||||
const regexPattern = term.replace(/\*/g, '.*').toLowerCase();
|
||||
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
|
||||
} else {
|
||||
// Match whole word only
|
||||
const regex = new RegExp(`\\b${term}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
return testRegex(getCompiledRegex(`\\b${term}\\b`, 'i'), textLower);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Handle wildcard patterns
|
||||
else if (pattern.includes('*')) {
|
||||
const regexPattern = pattern.replace(/\*/g, '.*').toLowerCase();
|
||||
const regex = new RegExp(`\\b${regexPattern}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
return testRegex(getCompiledRegex(`\\b${regexPattern}\\b`, 'i'), textLower);
|
||||
}
|
||||
// Simple whole word match
|
||||
else {
|
||||
const regex = new RegExp(`\\b${pattern.toLowerCase()}\\b`, 'i');
|
||||
return regex.test(textLower);
|
||||
return testRegex(getCompiledRegex(`\\b${pattern.toLowerCase()}\\b`, 'i'), textLower);
|
||||
}
|
||||
} catch (error) {
|
||||
// If regex parsing fails, fall back to simple includes
|
||||
@@ -233,7 +257,7 @@ export const commentMatchesPattern = (comment: Comment, pattern: string): boolea
|
||||
|
||||
// If there's also a content filter, check if the comment matches it as well
|
||||
if (contentFilter) {
|
||||
return allSpecialFiltersMatch && matchesPattern((comment?.title || '') + ' ' + (comment?.content || ''), contentFilter);
|
||||
return allSpecialFiltersMatch && matchesPattern(getCommentSearchText(comment), contentFilter);
|
||||
}
|
||||
|
||||
return allSpecialFiltersMatch;
|
||||
@@ -260,9 +284,5 @@ export const commentMatchesPattern = (comment: Comment, pattern: string): boolea
|
||||
}
|
||||
|
||||
// Regular content matching
|
||||
const titleLower = comment?.title?.toLowerCase() || '';
|
||||
const contentLower = comment?.content?.toLowerCase() || '';
|
||||
const textToMatch = titleLower + ' ' + contentLower;
|
||||
|
||||
return matchesPattern(textToMatch, pattern);
|
||||
return matchesPattern(getCommentSearchText(comment), pattern);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import i18next from 'i18next';
|
||||
|
||||
export const getFormattedDate = (commentTimestamp: number) => {
|
||||
if (commentTimestamp === undefined || isNaN(commentTimestamp)) {
|
||||
return '';
|
||||
}
|
||||
const locale = i18next.language || 'en';
|
||||
const string = new Intl.DateTimeFormat(locale, {
|
||||
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
const getDateTimeFormatter = (locale: string) => {
|
||||
const cachedFormatter = dateTimeFormatters.get(locale);
|
||||
if (cachedFormatter) return cachedFormatter;
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
hour12: false,
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
@@ -14,7 +15,23 @@ export const getFormattedDate = (commentTimestamp: number) => {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(new Date(commentTimestamp * 1000));
|
||||
});
|
||||
dateTimeFormatters.set(locale, formatter);
|
||||
return formatter;
|
||||
};
|
||||
|
||||
if (typeof i18next.on === 'function') {
|
||||
i18next.on('languageChanged', () => {
|
||||
dateTimeFormatters.clear();
|
||||
});
|
||||
}
|
||||
|
||||
export const getFormattedDate = (commentTimestamp: number) => {
|
||||
if (commentTimestamp === undefined || isNaN(commentTimestamp)) {
|
||||
return '';
|
||||
}
|
||||
const locale = i18next.language || 'en';
|
||||
const string = getDateTimeFormatter(locale).format(new Date(commentTimestamp * 1000));
|
||||
if (locale.startsWith('ar')) {
|
||||
return string;
|
||||
}
|
||||
|
||||
@@ -11,12 +11,44 @@ export const getHostname = (url: string) => {
|
||||
};
|
||||
|
||||
export const isValidURL = (url: string) => {
|
||||
return parseHttpUrl(url) !== null;
|
||||
};
|
||||
|
||||
export const parseHttpUrl = (url: string): URL | null => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isPrivateNetworkHostname = (hostname: string): boolean => {
|
||||
const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
|
||||
if (
|
||||
normalizedHostname === 'localhost' ||
|
||||
normalizedHostname.endsWith('.localhost') ||
|
||||
normalizedHostname.endsWith('.local') ||
|
||||
normalizedHostname === '0.0.0.0' ||
|
||||
normalizedHostname === '::1' ||
|
||||
normalizedHostname === '::' ||
|
||||
normalizedHostname.startsWith('::ffff:')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipv4Parts = normalizedHostname.split('.').map((part) => Number(part));
|
||||
if (ipv4Parts.length === 4 && ipv4Parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
||||
const [first, second] = ipv4Parts;
|
||||
return first === 10 || first === 127 || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168);
|
||||
}
|
||||
|
||||
if (!normalizedHostname.includes(':')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedHostname.startsWith('fc') || normalizedHostname.startsWith('fd') || normalizedHostname.startsWith('fe80:');
|
||||
};
|
||||
|
||||
export const normalizePublishURL = (url: string) => {
|
||||
|
||||
Vendored
-4
@@ -3,8 +3,4 @@ declare module '*.module.css' {
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module 'lodash';
|
||||
|
||||
declare module 'react-draggable';
|
||||
|
||||
declare module 'react-router-hash-link';
|
||||
|
||||
+2
-10
@@ -16,14 +16,6 @@ if (!window.fetch) {
|
||||
console.warn('Fetch API is not available, using polyfill');
|
||||
}
|
||||
|
||||
// For crypto support
|
||||
if (typeof window.crypto === 'undefined') {
|
||||
window.crypto = {};
|
||||
}
|
||||
if (typeof window.crypto.getRandomValues === 'undefined') {
|
||||
window.crypto.getRandomValues = function (array) {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
};
|
||||
if (typeof window.crypto === 'undefined' || typeof window.crypto.getRandomValues !== 'function') {
|
||||
throw new Error('crypto.getRandomValues is required for secure account and signature operations.');
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('useCatalogFiltersStore', () => {
|
||||
expect(persisted).not.toContain('"filteredCids"');
|
||||
});
|
||||
|
||||
it('applies search and content filters, hides matching comments, and counts hidden cids only once per board', async () => {
|
||||
it('applies search and content filters without mutating counts from the predicate', async () => {
|
||||
const useCatalogFiltersStore = await loadStore();
|
||||
|
||||
useCatalogFiltersStore.getState().setFilterItems([createFilterItem('spam', { hide: true }), createFilterItem('highlight', { hide: false, top: true })] as never);
|
||||
@@ -103,15 +103,13 @@ describe('useCatalogFiltersStore', () => {
|
||||
expect(filter?.({ cid: 'cid-3', content: 'topic highlight', communityAddress: 'music.eth' } as never)).toBe(true);
|
||||
expect(filter?.({ cid: 'cid-4', content: 'ordinary update', communityAddress: 'music.eth' } as never)).toBe(false);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
const state = useCatalogFiltersStore.getState();
|
||||
expect(testState.commentMatchesPatternMock).toHaveBeenCalled();
|
||||
expect(state.filterItems[0].count).toBe(1);
|
||||
expect(state.filterItems[0].filteredCids).toEqual(new Set(['cid-1']));
|
||||
expect(state.filterItems[0].communityCounts.get('music.eth')).toBe(1);
|
||||
expect(state.filteredCount).toBe(1);
|
||||
expect(state.getFilteredCountForCurrentCommunity()).toBe(1);
|
||||
expect(state.filterItems[0].count).toBe(0);
|
||||
expect(state.filterItems[0].filteredCids).toEqual(new Set());
|
||||
expect(state.filterItems[0].communityCounts.get('music.eth')).toBe(0);
|
||||
expect(state.filteredCount).toBe(0);
|
||||
expect(state.getFilteredCountForCurrentCommunity()).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves counts for unchanged filters when saving and clears matched filters', async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
|
||||
import { commentMatchesPattern } from '../lib/utils/pattern-utils';
|
||||
|
||||
interface FilterItem {
|
||||
@@ -72,6 +71,25 @@ const normalizeFilterItem = (item: RawFilterItem): FilterItem => {
|
||||
};
|
||||
};
|
||||
|
||||
const createCatalogFilter = (get: () => CatalogFiltersStore) => (comment: Comment) => {
|
||||
if (!comment?.cid) return true;
|
||||
|
||||
const state = get();
|
||||
if (state.searchText.trim() !== '' && !commentMatchesPattern(comment, state.searchText)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let shouldHide = false;
|
||||
for (const item of state.filterItems) {
|
||||
if (item.enabled && item.text.trim() !== '' && item.hide && commentMatchesPattern(comment, item.text)) {
|
||||
shouldHide = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return !shouldHide;
|
||||
};
|
||||
|
||||
const useCatalogFiltersStore = create(
|
||||
persist<CatalogFiltersStore>(
|
||||
(set, get) => ({
|
||||
@@ -138,9 +156,7 @@ const useCatalogFiltersStore = create(
|
||||
};
|
||||
});
|
||||
|
||||
// Recalculate the filtered count for the current community
|
||||
get().recalcFilteredCount();
|
||||
get().updateFilter();
|
||||
} else {
|
||||
set({ currentCommunityAddress: address });
|
||||
}
|
||||
@@ -148,11 +164,9 @@ const useCatalogFiltersStore = create(
|
||||
searchText: '',
|
||||
setSearchFilter: (text: string) => {
|
||||
set({ searchText: text });
|
||||
get().updateFilter();
|
||||
},
|
||||
clearSearchFilter: () => {
|
||||
set({ searchText: '' });
|
||||
get().updateFilter();
|
||||
},
|
||||
setFilterItems: (items: FilterItem[]) => {
|
||||
const nonEmptyItems = items.filter((item) => item.text.trim() !== '').map((item) => normalizeFilterItem(item));
|
||||
@@ -200,57 +214,10 @@ const useCatalogFiltersStore = create(
|
||||
});
|
||||
|
||||
get().recalcFilteredCount();
|
||||
get().updateFilter();
|
||||
},
|
||||
filter: undefined,
|
||||
updateFilter: () => {
|
||||
set((state) => ({
|
||||
filter: (comment: Comment) => {
|
||||
if (!comment?.cid) return true;
|
||||
|
||||
const currentCommunityAddress = state.currentCommunityAddress;
|
||||
|
||||
// Apply search filter
|
||||
if (state.searchText.trim() !== '') {
|
||||
if (!commentMatchesPattern(comment, state.searchText)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply content filters
|
||||
const { filterItems } = state;
|
||||
let shouldHide = false;
|
||||
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
||||
|
||||
for (let i = 0; i < filterItems.length; i++) {
|
||||
const item = filterItems[i];
|
||||
if (item.enabled && item.text.trim() !== '') {
|
||||
if (commentMatchesPattern(comment, item.text)) {
|
||||
// If we have a current community and this is a match, increment the count
|
||||
if (currentCommunityAddress && commentCommunityAddress && commentCommunityAddress === currentCommunityAddress) {
|
||||
// We need to use a timeout to avoid modifying state during a state update
|
||||
setTimeout(() => {
|
||||
const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled);
|
||||
if (filterIndex !== -1) {
|
||||
get().incrementFilterCount(filterIndex, comment.cid, commentCommunityAddress);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (item.hide) {
|
||||
shouldHide = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !shouldHide;
|
||||
},
|
||||
}));
|
||||
},
|
||||
initializeFilter: () => {
|
||||
get().updateFilter();
|
||||
},
|
||||
filter: createCatalogFilter(get),
|
||||
updateFilter: () => undefined,
|
||||
initializeFilter: () => undefined,
|
||||
incrementFilterCount: (filterIndex: number, cid: string, communityAddress: string) => {
|
||||
set((state) => {
|
||||
const newFilterItems = [...state.filterItems];
|
||||
@@ -351,9 +318,6 @@ const useCatalogFiltersStore = create(
|
||||
filteredCount: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Trigger filter reapplication to start counting again
|
||||
get().updateFilter();
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -365,6 +329,7 @@ const useCatalogFiltersStore = create(
|
||||
enabled: item.enabled,
|
||||
hide: item.hide,
|
||||
top: item.top,
|
||||
color: item.color,
|
||||
})),
|
||||
} as any;
|
||||
},
|
||||
@@ -395,6 +360,4 @@ const useCatalogFiltersStore = create(
|
||||
),
|
||||
);
|
||||
|
||||
useCatalogFiltersStore.getState().updateFilter();
|
||||
|
||||
export default useCatalogFiltersStore;
|
||||
|
||||
@@ -108,6 +108,14 @@ const useModQueueStore = create<ModQueueState>()(
|
||||
};
|
||||
return current as ModQueueState;
|
||||
},
|
||||
partialize: (state): PersistedModQueueData => ({
|
||||
alertThresholdValue: state.alertThresholdValue,
|
||||
alertThresholdUnit: state.alertThresholdUnit,
|
||||
dismissedCommentCids: state.dismissedCommentCids,
|
||||
queuedCommentHistory: state.queuedCommentHistory,
|
||||
selectedBoardFilter: state.selectedBoardFilter,
|
||||
viewMode: state.viewMode,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -233,6 +233,7 @@ vi.mock('../../../hooks/use-state-string', () => ({
|
||||
|
||||
vi.mock('../../../hooks/use-window-width', () => ({
|
||||
default: () => testState.windowWidth,
|
||||
useIsMobileBreakpoint: () => testState.windowWidth < 640,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-catalog-style-store', () => ({
|
||||
@@ -250,9 +251,10 @@ vi.mock('../../../stores/use-feed-reset-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-sorting-store', () => ({
|
||||
default: () => ({
|
||||
sortType: testState.sortType,
|
||||
}),
|
||||
default: (selector?: (state: { sortType: typeof testState.sortType }) => unknown) => {
|
||||
const state = { sortType: testState.sortType };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-catalog-filters-store', () => ({
|
||||
|
||||
@@ -272,7 +272,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
return resolvedAddressFromUrl;
|
||||
}, [boardIdentifierProp, directories, resolvedAddressFromUrl]);
|
||||
|
||||
const { filterItems, searchText } = useCatalogFiltersStore();
|
||||
const filterItems = useCatalogFiltersStore((state) => state.filterItems);
|
||||
const searchText = useCatalogFiltersStore((state) => state.searchText);
|
||||
|
||||
const account = useAccount();
|
||||
const subscriptions = account?.subscriptions;
|
||||
@@ -306,7 +307,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
const communities = useCommunityIdentifiers(communityAddresses);
|
||||
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
||||
|
||||
const { imageSize, showOPComment } = useCatalogStyleStore();
|
||||
const imageSize = useCatalogStyleStore((state) => state.imageSize);
|
||||
const showOPComment = useCatalogStyleStore((state) => state.showOPComment);
|
||||
const columnWidth = imageSize === 'Large' ? 270 : 180;
|
||||
const windowWidth = useWindowWidth();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -325,7 +327,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
}
|
||||
}, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, location.search, navigate]);
|
||||
|
||||
const { sortType } = useSortingStore();
|
||||
const sortType = useSortingStore((state) => state.sortType);
|
||||
const feedSortType = sortType === 'new' ? 'new' : 'active';
|
||||
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
|
||||
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
|
||||
@@ -632,7 +634,6 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
effectiveInfiniteScroll,
|
||||
],
|
||||
);
|
||||
|
||||
const isFeedLoaded = feed.length > 0 || state === 'failed';
|
||||
|
||||
// Process the feed to move "top" posts to the top (applied after display sort)
|
||||
|
||||
@@ -66,7 +66,8 @@ const PopularThreadCard = memo(
|
||||
|
||||
const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: DirectoryCommunity[]; directoryAddresses: string[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
|
||||
const showWorksafeContentOnly = usePopularThreadsOptionsStore((state) => state.showWorksafeContentOnly);
|
||||
const showNsfwContentOnly = usePopularThreadsOptionsStore((state) => state.showNsfwContentOnly);
|
||||
|
||||
const filteredBoardAddresses = useMemo(() => {
|
||||
return directoryAddresses.flatMap((address) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
||||
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import styles from './mod-queue.module.css';
|
||||
import useModQueueStore from '../../stores/use-mod-queue-store';
|
||||
@@ -47,6 +48,62 @@ const getBoardDisplayPath = (address: string, path: string): string => {
|
||||
return getShortAddress(address) || address;
|
||||
};
|
||||
|
||||
type LocalModerationEditSummary = Record<string, { timestamp?: number; value: unknown } | undefined>;
|
||||
type LocalModerationEditSummaries = Record<string, LocalModerationEditSummary | undefined>;
|
||||
|
||||
const LOCAL_MODERATION_EDIT_FIELDS = ['approved', 'removed'] as const;
|
||||
const LOCAL_EDIT_PENDING_SECONDS = 20 * 60;
|
||||
|
||||
const shouldApplyLocalModerationEdit = (
|
||||
comment: Comment,
|
||||
propertyName: (typeof LOCAL_MODERATION_EDIT_FIELDS)[number],
|
||||
edit: { timestamp?: number; value: unknown },
|
||||
now: number,
|
||||
) => {
|
||||
const editTimestamp = edit.timestamp ?? 0;
|
||||
const updatedAt = (comment as { updatedAt?: number }).updatedAt;
|
||||
const currentValue = (comment as Record<string, unknown>)[propertyName];
|
||||
|
||||
if (!updatedAt) {
|
||||
return Object.is(currentValue, edit.value) || editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS;
|
||||
}
|
||||
|
||||
if (updatedAt < editTimestamp || Object.is(currentValue, edit.value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS || updatedAt - editTimestamp < LOCAL_EDIT_PENDING_SECONDS;
|
||||
};
|
||||
|
||||
const applyLocalModerationEdits = (comment: Comment, editSummary: LocalModerationEditSummary | undefined, now: number): Comment => {
|
||||
if (!editSummary) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
let editedComment: Comment | undefined;
|
||||
for (const propertyName of LOCAL_MODERATION_EDIT_FIELDS) {
|
||||
const edit = editSummary[propertyName];
|
||||
if (!edit || edit.value === undefined || !shouldApplyLocalModerationEdit(comment, propertyName, edit, now)) {
|
||||
continue;
|
||||
}
|
||||
editedComment = { ...(editedComment ?? comment), [propertyName]: edit.value } as Comment;
|
||||
}
|
||||
|
||||
return editedComment ?? comment;
|
||||
};
|
||||
|
||||
const useLocallyModeratedModQueueFeed = (feed: Comment[], currentTime: number) => {
|
||||
const accountId = useAccountsStore((state) => state.activeAccountId);
|
||||
const editSummaries = useAccountsStore((state) => (accountId ? state.accountsEditsSummaries[accountId] : undefined)) as LocalModerationEditSummaries | undefined;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!editSummaries) {
|
||||
return feed;
|
||||
}
|
||||
return feed.map((comment) => (comment.cid ? applyLocalModerationEdits(comment, editSummaries[comment.cid], currentTime) : comment));
|
||||
}, [currentTime, editSummaries, feed]);
|
||||
};
|
||||
|
||||
interface ModQueueViewProps {
|
||||
boardIdentifier?: string; // If provided, shows queue for single board
|
||||
}
|
||||
@@ -305,7 +362,7 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
||||
|
||||
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath, boardDisplayPath }: ModQueueRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { getAlertThresholdSeconds } = useModQueueStore();
|
||||
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
|
||||
const isMobile = useIsMobile();
|
||||
const currentTime = useCurrentTime();
|
||||
|
||||
@@ -319,7 +376,7 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
||||
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
||||
|
||||
// Only show alert animation for comments awaiting approval (not approved or rejected)
|
||||
const isAwaitingApproval = isPendingApprovalAwaiting(comment);
|
||||
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
|
||||
|
||||
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
|
||||
const hasTitle = title && title.trim().length > 0;
|
||||
@@ -413,7 +470,7 @@ interface ModQueueCardProps {
|
||||
|
||||
const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplayPath }: ModQueueCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { getAlertThresholdSeconds } = useModQueueStore();
|
||||
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
|
||||
const currentTime = useCurrentTime();
|
||||
|
||||
const { editedComment } = useEditedComment({ comment });
|
||||
@@ -424,7 +481,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
||||
const timeWaiting = currentTime - timestamp;
|
||||
const alertThresholdSeconds = getAlertThresholdSeconds();
|
||||
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
||||
const isAwaitingApproval = isPendingApprovalAwaiting(comment);
|
||||
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
|
||||
|
||||
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
|
||||
const hasTitle = title && title.trim().length > 0;
|
||||
@@ -533,17 +590,20 @@ const findBoardAddressByCode = (code: string, dirs: DirectoryCommunity[]): strin
|
||||
|
||||
const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: ModQueueBoardSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedBoardFilter, setSelectedBoardFilter, getAlertThresholdSeconds } = useModQueueStore();
|
||||
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
|
||||
const setSelectedBoardFilter = useModQueueStore((state) => state.setSelectedBoardFilter);
|
||||
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
|
||||
const currentTime = useCurrentTime();
|
||||
const alertThresholdSeconds = getAlertThresholdSeconds();
|
||||
const modAddressSet = useMemo(() => new Set(accountCommunityAddresses), [accountCommunityAddresses]);
|
||||
const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime);
|
||||
|
||||
const boardCounts = useMemo(() => {
|
||||
const counts = new Map<string, { normal: number; urgent: number }>();
|
||||
for (const address of accountCommunityAddresses) {
|
||||
counts.set(address, { normal: 0, urgent: 0 });
|
||||
}
|
||||
for (const item of feed) {
|
||||
for (const item of locallyModeratedFeed) {
|
||||
const addr = getCommentCommunityAddress(item);
|
||||
if (!addr) continue;
|
||||
const entry = counts.get(addr);
|
||||
@@ -556,7 +616,7 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }:
|
||||
else entry.normal++;
|
||||
}
|
||||
return counts;
|
||||
}, [feed, accountCommunityAddresses, currentTime, alertThresholdSeconds]);
|
||||
}, [locallyModeratedFeed, accountCommunityAddresses, currentTime, alertThresholdSeconds]);
|
||||
|
||||
const { totalNormal, totalUrgent } = useMemo(() => {
|
||||
let normal = 0;
|
||||
@@ -663,29 +723,6 @@ interface ModQueueButtonProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
interface ModQueueCountItemProps {
|
||||
comment: Comment;
|
||||
alertThresholdSeconds: number;
|
||||
onStatusChange: (cid: string, status: { awaiting: boolean; urgent: boolean }) => void;
|
||||
}
|
||||
|
||||
const ModQueueCountItem = ({ comment, alertThresholdSeconds, onStatusChange }: ModQueueCountItemProps) => {
|
||||
const { editedComment } = useEditedComment({ comment });
|
||||
const displayComment = editedComment || comment;
|
||||
const currentTime = useCurrentTime();
|
||||
|
||||
const { cid, timestamp } = displayComment;
|
||||
const isAwaiting = isPendingApprovalAwaiting(displayComment);
|
||||
const timeWaiting = currentTime - timestamp;
|
||||
const isUrgent = isAwaiting && timeWaiting > alertThresholdSeconds;
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange(cid, { awaiting: isAwaiting, urgent: isUrgent });
|
||||
}, [cid, isAwaiting, isUrgent, onStatusChange]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface ModQueueButtonContentProps {
|
||||
feed: Comment[];
|
||||
alertThresholdSeconds: number;
|
||||
@@ -695,84 +732,54 @@ interface ModQueueButtonContentProps {
|
||||
|
||||
const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [statusMap, setStatusMap] = useState<Map<string, { awaiting: boolean; urgent: boolean }>>(new Map());
|
||||
|
||||
const handleStatusChange = React.useCallback((cid: string, status: { awaiting: boolean; urgent: boolean }) => {
|
||||
setStatusMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(cid, status);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Clean up stale entries when comments leave the feed to prevent memory leaks
|
||||
const feedCids = useMemo(() => new Set(feed.map((item) => item.cid)), [feed]);
|
||||
useEffect(() => {
|
||||
setStatusMap((prev) => {
|
||||
const staleKeys = [...prev.keys()].filter((cid) => !feedCids.has(cid));
|
||||
if (staleKeys.length === 0) return prev;
|
||||
const next = new Map(prev);
|
||||
for (const key of staleKeys) {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [feedCids]);
|
||||
const currentTime = useCurrentTime();
|
||||
const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime);
|
||||
|
||||
const { normalCount, urgentCount } = useMemo(() => {
|
||||
let normal = 0;
|
||||
let urgent = 0;
|
||||
for (const { awaiting, urgent: isUrgent } of statusMap.values()) {
|
||||
if (awaiting) {
|
||||
if (isUrgent) urgent++;
|
||||
else normal++;
|
||||
}
|
||||
for (const comment of locallyModeratedFeed) {
|
||||
if (!isPendingApprovalAwaiting(comment)) continue;
|
||||
const timeWaiting = currentTime - (comment.timestamp ?? 0);
|
||||
if (timeWaiting > alertThresholdSeconds) urgent++;
|
||||
else normal++;
|
||||
}
|
||||
return { normalCount: normal, urgentCount: urgent };
|
||||
}, [statusMap]);
|
||||
}, [alertThresholdSeconds, currentTime, locallyModeratedFeed]);
|
||||
|
||||
const totalCount = normalCount + urgentCount;
|
||||
const to = boardIdentifier ? `/${boardIdentifier}/mod/queue` : '/mod/queue';
|
||||
|
||||
const buttonContent = (
|
||||
<button className='button'>
|
||||
<Link to={to}>
|
||||
{t('mod_queue')}
|
||||
{totalCount > 0 && (
|
||||
<strong>
|
||||
(
|
||||
{urgentCount > 0 && normalCount > 0 ? (
|
||||
<>
|
||||
<span className={styles.modQueueButtonCount}>{normalCount}</span>
|
||||
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>
|
||||
{'+'}
|
||||
{urgentCount}
|
||||
</span>
|
||||
</>
|
||||
) : urgentCount > 0 ? (
|
||||
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgentCount}</span>
|
||||
) : (
|
||||
<span className={styles.modQueueButtonCount}>{totalCount}</span>
|
||||
)}
|
||||
)
|
||||
</strong>
|
||||
)}
|
||||
</Link>
|
||||
</button>
|
||||
<Link className='button' to={to}>
|
||||
{t('mod_queue')}
|
||||
{totalCount > 0 && (
|
||||
<strong>
|
||||
(
|
||||
{urgentCount > 0 && normalCount > 0 ? (
|
||||
<>
|
||||
<span className={styles.modQueueButtonCount}>{normalCount}</span>
|
||||
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>
|
||||
{'+'}
|
||||
{urgentCount}
|
||||
</span>
|
||||
</>
|
||||
) : urgentCount > 0 ? (
|
||||
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgentCount}</span>
|
||||
) : (
|
||||
<span className={styles.modQueueButtonCount}>{totalCount}</span>
|
||||
)}
|
||||
)
|
||||
</strong>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{feed.map((item) => (
|
||||
<ModQueueCountItem key={item.cid} comment={item} alertThresholdSeconds={alertThresholdSeconds} onStatusChange={handleStatusChange} />
|
||||
))}
|
||||
{isMobile ? buttonContent : <>[{buttonContent}]</>}
|
||||
</>
|
||||
);
|
||||
return isMobile ? buttonContent : <>[{buttonContent}]</>;
|
||||
};
|
||||
|
||||
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
|
||||
const { getAlertThresholdSeconds } = useModQueueStore();
|
||||
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
|
||||
|
||||
const account = useAccount();
|
||||
const accountAddress = account?.author?.address;
|
||||
@@ -834,7 +841,7 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
|
||||
}
|
||||
|
||||
const alertThresholdSeconds = getAlertThresholdSeconds();
|
||||
// Use key to reset statusMap state when switching boards (prevents stale counts from previous board)
|
||||
// Remount when switching boards so memoized counts reset cleanly.
|
||||
const contentKey = communityAddresses.join(',');
|
||||
return <ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
|
||||
};
|
||||
@@ -842,7 +849,11 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
|
||||
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const { selectedBoardFilter, viewMode, dismissedCommentCids, queuedCommentHistory, rememberCommentsInQueue } = useModQueueStore();
|
||||
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
|
||||
const viewMode = useModQueueStore((state) => state.viewMode);
|
||||
const dismissedCommentCids = useModQueueStore((state) => state.dismissedCommentCids);
|
||||
const queuedCommentHistory = useModQueueStore((state) => state.queuedCommentHistory);
|
||||
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const accountCommunityAddresses = useAccountCommunityAddresses();
|
||||
|
||||
@@ -23,11 +23,16 @@ import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
|
||||
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
||||
import styles from './post.module.css';
|
||||
|
||||
type CommentWithRefresh = Comment & {
|
||||
export type CommentWithRefresh = Comment & {
|
||||
approved?: boolean;
|
||||
communityAddress?: string;
|
||||
refresh?: () => Promise<void>;
|
||||
state?: string;
|
||||
pendingApproval?: boolean;
|
||||
error?: Error;
|
||||
errors?: Error[];
|
||||
index?: number;
|
||||
removed?: boolean;
|
||||
};
|
||||
|
||||
const getRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
|
||||
@@ -134,9 +139,9 @@ export interface PostProps {
|
||||
index?: number;
|
||||
isHidden?: boolean;
|
||||
hasThumbnail?: boolean;
|
||||
post?: any;
|
||||
post?: CommentWithRefresh;
|
||||
postReplyCount?: number;
|
||||
reply?: any;
|
||||
reply?: Comment;
|
||||
replyPaginationOverride?: ReplyPaginationOverride;
|
||||
replyVirtualizationModeOverride?: ReplyVirtualizationMode;
|
||||
roles?: Role[];
|
||||
@@ -364,10 +369,7 @@ const PostPage = () => {
|
||||
const queuedReplyHasMore = queuedReplyRepliesResult.hasMore;
|
||||
const queuedReplyLoadMore = queuedReplyRepliesResult.loadMore;
|
||||
const queuedReplyReset = (queuedReplyRepliesResult as { reset?: () => Promise<void> }).reset;
|
||||
const queuedReplyReplies =
|
||||
((queuedReplyRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
|
||||
? (queuedReplyRepliesResult as { updatedReplies?: Comment[] }).updatedReplies
|
||||
: queuedReplyRepliesResult.replies) || [];
|
||||
const queuedReplyReplies = (queuedReplyRepliesResult.updatedReplies?.length ? queuedReplyRepliesResult.updatedReplies : queuedReplyRepliesResult.replies) || [];
|
||||
const replyPaginationOverride = useMemo(() => {
|
||||
if (!queuedReply || !post?.cid) return undefined;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user