mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(accounts): adapt 5chan to compact account history hooks (#1118)
* chore(cursor): use composer-2 for subagents * fix(accounts): adapt 5chan to compact account history hooks * fix(accounts): address 5chan account-history review findings
This commit is contained in:
+3
-2
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
import { useAccount, useAccountComment, useCommunity } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { useAccount, useCommunity } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { initSnow, removeSnow } from './lib/snow';
|
||||
import { isAllView, isCatalogView, isModView, isSubscriptionsView } from './lib/utils/view-utils';
|
||||
import { preloadReplyModal, preloadThemeAssets } from './lib/utils/preload-utils';
|
||||
@@ -13,6 +13,7 @@ import { useAccountCommunityAddresses } from './hooks/use-account-community-addr
|
||||
import useTheme from './hooks/use-theme';
|
||||
import { useDirectories } from './hooks/use-directories';
|
||||
import { useResolvedCommunityAddress } from './hooks/use-resolved-community-address';
|
||||
import useSafeAccountComment from './hooks/use-safe-account-comment';
|
||||
import {
|
||||
getBoardPath,
|
||||
getSubplebbitAddress,
|
||||
@@ -71,7 +72,7 @@ const BoardLayout = () => {
|
||||
const isInModView = isModView(location.pathname);
|
||||
const directories = useDirectories();
|
||||
const communityAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : undefined;
|
||||
const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined });
|
||||
const pendingPost = useSafeAccountComment({ commentIndex: accountCommentIndex });
|
||||
const pendingPostCommunityAddress = pendingPost?.communityAddress || pendingPost?.subplebbitAddress;
|
||||
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
||||
const isOnPostRoute = isPostRoute(location.pathname);
|
||||
|
||||
@@ -72,6 +72,7 @@ vi.mock('react-router-dom', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => undefined,
|
||||
useAccountComment: () => testState.accountComment,
|
||||
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
|
||||
useSubscribe: () => ({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAccountComment, useComment, useSubscribe } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { useComment, useSubscribe } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { isAllView, isCatalogView, isModView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { usePostPageNumber } from '../../hooks/use-post-page-number';
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import { getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
@@ -387,7 +388,7 @@ export const MobileBoardButtons = () => {
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInModQueueView = isModQueueView(location.pathname);
|
||||
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
|
||||
@@ -491,7 +492,7 @@ export const PostPageStats = () => {
|
||||
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
|
||||
const commentCid = params?.commentCid as string | undefined;
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
|
||||
const comment = useComment({ commentCid, autoUpdate: autoUpdateEnabled });
|
||||
@@ -535,7 +536,7 @@ export const DesktopBoardButtons = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock('react-router-dom', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => undefined,
|
||||
useAccountComment: () => testState.accountComment,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import useAccountsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts';
|
||||
import useCommunitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
@@ -11,6 +10,7 @@ import { isArchiveRoute } from '../../lib/utils/route-utils';
|
||||
import styles from './board-header.module.css';
|
||||
import { useDirectoriesMetadata, useDirectories } from '../../hooks/use-directories';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
@@ -53,7 +53,7 @@ const BoardHeader = () => {
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInArchiveView = isArchiveRoute(location.pathname);
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('react-router-dom', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => undefined,
|
||||
useAccountComment: () => testState.accountComment,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
import { useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import useAccountsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts';
|
||||
import { isAllView, isCatalogView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||
import { useDirectories, useDirectoriesMetadata, DirectoryCommunity } from '../../hooks/use-directories';
|
||||
import { useBoardPath, useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import { getBoardPath, extractDirectoryFromTitle } from '../../lib/utils/route-utils';
|
||||
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||
import useCreateBoardModalStore from '../../stores/use-create-board-modal-store';
|
||||
@@ -414,8 +414,8 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
|
||||
|
||||
const BoardsBar = () => {
|
||||
const params = useParams();
|
||||
const commentIndex = params?.accountCommentIndex ? parseInt(params.accountCommentIndex) : undefined;
|
||||
const accountComment = useAccountComment({ commentIndex });
|
||||
const commentIndex = params?.accountCommentIndex ? parseInt(params.accountCommentIndex, 10) : undefined;
|
||||
const accountComment = useSafeAccountComment({ commentIndex });
|
||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||
const communityAddress = resolvedCommunityAddress || getCommentCommunityAddress(accountComment);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||
import { Comment, useEditedComment, useReplies, useAccount, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { Comment, useEditedComment, useReplies, useAccount } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
import styles from '../../views/post/post.module.css';
|
||||
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
|
||||
@@ -22,6 +22,7 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import useHide from '../../hooks/use-hide';
|
||||
import useStateString from '../../hooks/use-state-string';
|
||||
import useScrollToReply from '../../hooks/use-scroll-to-reply';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import { useCurrentTime } from '../../hooks/use-current-time';
|
||||
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
|
||||
import CommentContent from '../comment-content';
|
||||
@@ -714,7 +715,7 @@ const Reply = ({
|
||||
directRepliesByParentCid,
|
||||
postsByAuthorInThread,
|
||||
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number> }) => {
|
||||
const accountReply = useAccountComment({
|
||||
const accountReply = useSafeAccountComment({
|
||||
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
|
||||
});
|
||||
const hasReplyIndex = typeof reply?.index === 'number';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
|
||||
@@ -11,6 +11,7 @@ import { useAccountCommunityAddresses } from '../../hooks/use-account-community-
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import usePublishPost from '../../hooks/use-publish-post';
|
||||
import usePublishReply from '../../hooks/use-publish-reply';
|
||||
@@ -300,7 +301,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const [url, setUrl] = useState('');
|
||||
const author = account?.author || {};
|
||||
const { displayName } = author || {};
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
const { setPublishPostOptions, postIndex, publishPost, publishPostOptions, resetPublishPostOptions } = usePublishPost({ subplebbitAddress: communityAddress });
|
||||
@@ -532,7 +533,7 @@ const PostForm = () => {
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||
import { Comment, useEditedComment, useReplies, useAccount, usePublishCommentModeration, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { Comment, useEditedComment, useReplies, useAccount, usePublishCommentModeration } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
import styles from '../../views/post/post.module.css';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
@@ -21,6 +21,7 @@ import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||
import useHide from '../../hooks/use-hide';
|
||||
import useStateString from '../../hooks/use-state-string';
|
||||
import useScrollToReply from '../../hooks/use-scroll-to-reply';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import { useCurrentTime } from '../../hooks/use-current-time';
|
||||
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
|
||||
import CommentContent from '../comment-content';
|
||||
@@ -496,7 +497,7 @@ const Reply = ({
|
||||
directRepliesByParentCid,
|
||||
postsByAuthorInThread,
|
||||
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number> }) => {
|
||||
const accountReply = useAccountComment({
|
||||
const accountReply = useSafeAccountComment({
|
||||
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
|
||||
});
|
||||
const hasReplyIndex = typeof reply?.index === 'number';
|
||||
|
||||
@@ -19,11 +19,13 @@ type TestComment = {
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: {
|
||||
id: 'account-1',
|
||||
author: {
|
||||
address: '0xme',
|
||||
},
|
||||
},
|
||||
accountComments: [] as Array<{ cid?: string }>,
|
||||
} as { id?: string; author?: { address?: string } },
|
||||
accountCommentByCid: {} as Record<string, { cid?: string }>,
|
||||
accountCommentCalls: [] as Array<{ commentCid?: string } | undefined>,
|
||||
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
||||
isMobile: false,
|
||||
locationPath: '/mu/thread/thread-cid',
|
||||
@@ -59,7 +61,10 @@ vi.mock('react-router-dom', async () => {
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => testState.account,
|
||||
useAccountComments: () => ({ accountComments: testState.accountComments }),
|
||||
useAccountComment: (options?: { commentCid?: string }) => {
|
||||
testState.accountCommentCalls.push(options);
|
||||
return (options?.commentCid && testState.accountCommentByCid[options.commentCid]) || {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@floating-ui/react', () => ({
|
||||
@@ -171,11 +176,13 @@ describe('ReplyQuotePreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.account = {
|
||||
id: 'account-1',
|
||||
author: {
|
||||
address: '0xme',
|
||||
},
|
||||
};
|
||||
testState.accountComments = [];
|
||||
testState.accountCommentByCid = {};
|
||||
testState.accountCommentCalls = [];
|
||||
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
|
||||
testState.isMobile = false;
|
||||
testState.locationPath = '/mu/thread/thread-cid';
|
||||
@@ -394,6 +401,58 @@ describe('ReplyQuotePreview', () => {
|
||||
outOfView.remove();
|
||||
});
|
||||
|
||||
it('marks quotelinks as your own via the direct account comment lookup before author fallback', async () => {
|
||||
testState.account = {
|
||||
id: 'account-1',
|
||||
author: {
|
||||
address: '0xsomeone-else',
|
||||
},
|
||||
};
|
||||
testState.accountCommentByCid = {
|
||||
'reply-cid': {
|
||||
cid: 'reply-cid',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPreview({
|
||||
isQuotelinkReply: true,
|
||||
quotelinkReply: {
|
||||
author: {
|
||||
address: '0xother',
|
||||
},
|
||||
cid: 'reply-cid',
|
||||
number: 11,
|
||||
communityAddress: 'music-posting.eth',
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('>>11 (You)');
|
||||
expect(testState.accountCommentCalls).toContainEqual({ commentCid: 'reply-cid' });
|
||||
});
|
||||
|
||||
it('does not mark quotelinks as your own when the lookup misses and no author address matches', async () => {
|
||||
testState.account = {
|
||||
id: 'account-1',
|
||||
author: { address: undefined } as { address?: string },
|
||||
};
|
||||
testState.accountCommentByCid = {
|
||||
'reply-cid': {
|
||||
cid: 'different-cid',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPreview({
|
||||
isQuotelinkReply: true,
|
||||
quotelinkReply: {
|
||||
cid: 'reply-cid',
|
||||
number: 12,
|
||||
communityAddress: 'music-posting.eth',
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain('(You)');
|
||||
});
|
||||
|
||||
it('renders unavailable desktop quotelinks without navigation and includes OP/You labels', async () => {
|
||||
testState.quoteAvailability = 'unavailable';
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Comment, useAccount, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { Comment, useAccount } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { useFloating, offset, shift, size, autoUpdate, Placement } from '@floating-ui/react';
|
||||
import { useDirectories } from '../../hooks/use-directories';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import { getBoardPath } from '../../lib/utils/route-utils';
|
||||
import { formatQuoteNumber, getQuoteTargetAvailability, shouldShowFloatingQuotePreview } from '../../lib/utils/quote-link-utils';
|
||||
import { findPreferredScrollTarget, getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||
@@ -80,6 +81,17 @@ const scrollToReplyOnPage = (cid: string) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const useIsOwnQuotelink = (quotelinkReply?: Comment) => {
|
||||
const account = useAccount();
|
||||
const ownQuotelink = useSafeAccountComment({ commentCid: quotelinkReply?.cid });
|
||||
const quotedAuthorAddress = quotelinkReply?.author?.address;
|
||||
const accountAuthorAddress = account?.author?.address;
|
||||
|
||||
return Boolean(
|
||||
(quotelinkReply?.cid && ownQuotelink?.cid === quotelinkReply.cid) || (quotedAuthorAddress && accountAuthorAddress && quotedAuthorAddress === accountAuthorAddress),
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopQuotePreview = ({
|
||||
backlinkReply,
|
||||
quotelinkReply,
|
||||
@@ -204,10 +216,6 @@ const DesktopQuotePreview = ({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const account = useAccount();
|
||||
const { accountComments } = useAccountComments();
|
||||
|
||||
const resolvedQuotelinkNumber = normalizedQuotelinkReply?.number ?? quotelinkNumber;
|
||||
const resolvedQuotelinkCid = normalizedQuotelinkReply?.cid;
|
||||
const resolvedQuotelinkCommunityAddress = getCommentCommunityAddress(normalizedQuotelinkReply);
|
||||
@@ -227,9 +235,7 @@ const DesktopQuotePreview = ({
|
||||
quoteCid: resolvedQuotelinkCid,
|
||||
isUnavailable: quotelinkUnavailable,
|
||||
});
|
||||
const isOwnQuotelink =
|
||||
(resolvedQuotelinkCid ? accountComments.some((comment) => comment.cid === resolvedQuotelinkCid) : false) ||
|
||||
normalizedQuotelinkReply?.author?.address === account?.author?.address;
|
||||
const isOwnQuotelink = useIsOwnQuotelink(normalizedQuotelinkReply);
|
||||
const quotelinkLabel = (
|
||||
<>
|
||||
{formatQuoteNumber(resolvedQuotelinkNumber)}
|
||||
@@ -374,9 +380,6 @@ const MobileQuotePreview = ({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const account = useAccount();
|
||||
const { accountComments } = useAccountComments();
|
||||
const resolvedQuotelinkNumber = normalizedQuotelinkReply?.number ?? quotelinkNumber;
|
||||
const resolvedQuotelinkCid = normalizedQuotelinkReply?.cid;
|
||||
const resolvedQuotelinkCommunityAddress = getCommentCommunityAddress(normalizedQuotelinkReply);
|
||||
@@ -390,9 +393,7 @@ const MobileQuotePreview = ({
|
||||
quoteCid: resolvedQuotelinkCid,
|
||||
isUnavailable: quotelinkUnavailable,
|
||||
});
|
||||
const isOwnQuotelink =
|
||||
(resolvedQuotelinkCid ? accountComments.some((comment) => comment.cid === resolvedQuotelinkCid) : false) ||
|
||||
normalizedQuotelinkReply?.author?.address === account?.author?.address;
|
||||
const isOwnQuotelink = useIsOwnQuotelink(normalizedQuotelinkReply);
|
||||
|
||||
const replyQuotelink = (
|
||||
<>
|
||||
|
||||
@@ -7,15 +7,38 @@ import useFreshReplies from '../use-fresh-replies';
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
type TestComment = {
|
||||
cid?: string;
|
||||
content?: string;
|
||||
index?: number;
|
||||
number?: number;
|
||||
subplebbitAddress?: string;
|
||||
};
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
accountComments: [] as Array<Record<string, unknown>>,
|
||||
replies: [] as Array<Record<string, unknown>>,
|
||||
accountComments: [] as TestComment[],
|
||||
accountCommentsCalls: [] as Array<{ commentIndices?: number[] } | undefined>,
|
||||
replies: [] as TestComment[],
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccountComments: () => ({
|
||||
accountComments: testState.accountComments,
|
||||
}),
|
||||
useAccountComments: (options?: { commentIndices?: number[] }) => {
|
||||
testState.accountCommentsCalls.push(options);
|
||||
|
||||
if (!options?.commentIndices?.length) {
|
||||
return {
|
||||
accountComments: testState.accountComments,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedCommentIndices = options.commentIndices.filter((commentIndex) => Number.isInteger(commentIndex) && commentIndex >= 0);
|
||||
|
||||
return {
|
||||
accountComments: normalizedCommentIndices
|
||||
.map((commentIndex) => testState.accountComments.find((accountComment) => accountComment.index === commentIndex))
|
||||
.filter(Boolean),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
@@ -36,6 +59,7 @@ const renderHook = () => {
|
||||
describe('useFreshReplies', () => {
|
||||
beforeEach(() => {
|
||||
testState.accountComments = [];
|
||||
testState.accountCommentsCalls = [];
|
||||
testState.replies = [];
|
||||
|
||||
container = document.createElement('div');
|
||||
@@ -78,6 +102,7 @@ describe('useFreshReplies', () => {
|
||||
expect(latestValue[0]).toBe(testState.accountComments[0] as never);
|
||||
expect(latestValue[0]?.number).toBe(27);
|
||||
expect(latestValue[1]).toBe(testState.replies[1] as never);
|
||||
expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [3] });
|
||||
|
||||
testState.accountComments = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import useSafeAccountComment from '../use-safe-account-comment';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: undefined as { id?: string; name?: string } | undefined,
|
||||
accountCommentResult: { cid: 'account-comment' } as { cid?: string },
|
||||
calls: [] as Array<{ accountName?: string; commentCid?: string; commentIndex?: number }>,
|
||||
options: undefined as { accountName?: string; commentCid?: string; commentIndex?: number | string } | undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: (options?: { accountName?: string }) => {
|
||||
if (!options?.accountName) {
|
||||
return testState.account;
|
||||
}
|
||||
|
||||
return testState.account?.name === options.accountName ? testState.account : undefined;
|
||||
},
|
||||
useAccountComment: (options?: { accountName?: string; commentCid?: string; commentIndex?: number }) => {
|
||||
testState.calls.push(options || {});
|
||||
return testState.accountCommentResult;
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let latestValue: ReturnType<typeof useSafeAccountComment>;
|
||||
let root: Root;
|
||||
|
||||
const HookHarness = () => {
|
||||
latestValue = useSafeAccountComment(testState.options);
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHook = () => {
|
||||
act(() => {
|
||||
root.render(createElement(HookHarness));
|
||||
});
|
||||
};
|
||||
|
||||
describe('useSafeAccountComment', () => {
|
||||
beforeEach(() => {
|
||||
testState.account = undefined;
|
||||
testState.accountCommentResult = { cid: 'account-comment' };
|
||||
testState.calls = [];
|
||||
testState.options = undefined;
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('uses a sentinel lookup when there is no active account and no usable lookup input', () => {
|
||||
renderHook();
|
||||
|
||||
expect(testState.calls).toEqual([{ commentIndex: -1 }]);
|
||||
expect(latestValue?.cid).toBe('account-comment');
|
||||
});
|
||||
|
||||
it('normalizes numeric comment indices before delegating to useAccountComment', () => {
|
||||
testState.options = { commentIndex: '7' };
|
||||
|
||||
renderHook();
|
||||
|
||||
expect(testState.calls).toEqual([{ commentIndex: 7 }]);
|
||||
});
|
||||
|
||||
it('falls back to the sentinel lookup for malformed string indices', () => {
|
||||
testState.options = { commentIndex: '7abc' };
|
||||
|
||||
renderHook();
|
||||
|
||||
expect(testState.calls).toEqual([{ commentIndex: -1 }]);
|
||||
});
|
||||
|
||||
it('falls back to the sentinel lookup when cid lookup is requested before an account exists', () => {
|
||||
testState.options = { commentCid: 'reply-cid' };
|
||||
|
||||
renderHook();
|
||||
|
||||
expect(testState.calls).toEqual([{ commentIndex: -1 }]);
|
||||
});
|
||||
|
||||
it('passes comment cid lookups through once the active account exists', () => {
|
||||
testState.account = { id: 'account-1', name: 'Account 1' };
|
||||
testState.options = { commentCid: 'reply-cid' };
|
||||
|
||||
renderHook();
|
||||
|
||||
expect(testState.calls).toEqual([{ commentCid: 'reply-cid' }]);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAccountComments, type Community } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
|
||||
|
||||
const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolean, community: Community) => {
|
||||
const { address } = community || {};
|
||||
|
||||
const { accountComments } = useAccountComments();
|
||||
|
||||
const feedWithFakePostsOnTop = useMemo(() => {
|
||||
if (!isFeedLoaded) {
|
||||
return []; // prevent temporary/mock posts from appearing while the actual feed is loading
|
||||
}
|
||||
|
||||
const _feed = [...feed];
|
||||
|
||||
// show account comments instantly in the feed once published (cid defined), instead of waiting for the feed to update
|
||||
const filteredComments = accountComments.filter((comment) => {
|
||||
const { cid, deleted, postCid, removed, state, timestamp } = comment || {};
|
||||
const communityAddress = getCommentCommunityAddress(comment);
|
||||
|
||||
return (
|
||||
!deleted &&
|
||||
!removed &&
|
||||
timestamp > Date.now() - 60 * 60 * 1000 &&
|
||||
state === 'succeeded' &&
|
||||
cid &&
|
||||
cid === postCid &&
|
||||
communityAddress === address &&
|
||||
!_feed.some((feedItem) => feedItem.cid === cid)
|
||||
);
|
||||
});
|
||||
|
||||
// show newest account comment at the top of the feed but after pinned posts
|
||||
const lastPinnedIndex = _feed.map((post) => post.pinned).lastIndexOf(true);
|
||||
if (filteredComments.length > 0) {
|
||||
_feed.splice(
|
||||
lastPinnedIndex + 1,
|
||||
0,
|
||||
...filteredComments.map((comment) => ({
|
||||
...comment,
|
||||
isAccountComment: true,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return _feed;
|
||||
}, [accountComments, feed, address, isFeedLoaded]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const rows = [];
|
||||
for (let i = 0; i < feedWithFakePostsOnTop.length; i += columnCount) {
|
||||
rows.push(feedWithFakePostsOnTop.slice(i, i + columnCount));
|
||||
}
|
||||
return rows;
|
||||
}, [feedWithFakePostsOnTop, columnCount]);
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export default useCatalogFeedRows;
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Comment, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
|
||||
// Keep the hook on its indexed fast path when there are no reply indices to resolve.
|
||||
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
|
||||
|
||||
const useFreshReplies = (replies: Comment[] = []) => {
|
||||
const { accountComments } = useAccountComments();
|
||||
const replyIndices = useMemo(
|
||||
() => Array.from(new Set(replies.map((reply) => reply?.index).filter((replyIndex): replyIndex is number => typeof replyIndex === 'number'))),
|
||||
[replies],
|
||||
);
|
||||
const accountCommentLookupOptions = useMemo(() => (replyIndices.length > 0 ? { commentIndices: replyIndices } : EMPTY_ACCOUNT_COMMENT_LOOKUP), [replyIndices]);
|
||||
const { accountComments } = useAccountComments(accountCommentLookupOptions);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!replies.length || !accountComments?.length) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAccount, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
|
||||
type SafeAccountCommentOptions = {
|
||||
accountName?: string;
|
||||
commentCid?: string;
|
||||
commentIndex?: number | string;
|
||||
};
|
||||
|
||||
const EMPTY_ACCOUNT_COMMENT_LOOKUP = Object.freeze({ commentIndex: -1 as const });
|
||||
|
||||
const normalizeCommentIndex = (commentIndex: SafeAccountCommentOptions['commentIndex']) => {
|
||||
if (commentIndex === undefined || commentIndex === null || commentIndex === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedCommentIndex = Number(commentIndex);
|
||||
|
||||
return Number.isInteger(normalizedCommentIndex) && normalizedCommentIndex >= 0 ? normalizedCommentIndex : undefined;
|
||||
};
|
||||
|
||||
const useSafeAccountComment = (options?: SafeAccountCommentOptions) => {
|
||||
const account = useAccount(options?.accountName ? { accountName: options.accountName } : undefined);
|
||||
const normalizedCommentIndex = normalizeCommentIndex(options?.commentIndex);
|
||||
|
||||
const safeOptions = useMemo(() => {
|
||||
if (typeof normalizedCommentIndex === 'number') {
|
||||
return {
|
||||
...(options?.accountName ? { accountName: options.accountName } : {}),
|
||||
commentIndex: normalizedCommentIndex,
|
||||
};
|
||||
}
|
||||
|
||||
if (options?.commentCid && account?.id) {
|
||||
return {
|
||||
...(options?.accountName ? { accountName: options.accountName } : {}),
|
||||
commentCid: options.commentCid,
|
||||
};
|
||||
}
|
||||
|
||||
return EMPTY_ACCOUNT_COMMENT_LOOKUP;
|
||||
}, [account?.id, normalizedCommentIndex, options?.accountName, options?.commentCid]);
|
||||
|
||||
return useAccountComment(safeOptions);
|
||||
};
|
||||
|
||||
export default useSafeAccountComment;
|
||||
@@ -4,10 +4,10 @@ import { isAllView, isModView, isSubscriptionsView } from '../lib/utils/view-uti
|
||||
import useThemeStore from '../stores/use-theme-store';
|
||||
import { useDirectories } from './use-directories';
|
||||
import { useResolvedCommunityAddress } from './use-resolved-community-address';
|
||||
import { useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import useSpecialThemeStore from '../stores/use-special-theme-store';
|
||||
import { isChristmas } from '../lib/utils/time-utils';
|
||||
import { isSfwBoard, updateFavicon } from '../lib/update-favicon';
|
||||
import useSafeAccountComment from './use-safe-account-comment';
|
||||
|
||||
const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon'];
|
||||
|
||||
@@ -23,7 +23,7 @@ const useTheme = (): [string, (theme: string) => void] => {
|
||||
const params = useParams<{ boardIdentifier?: string; subplebbitAddress?: string }>();
|
||||
const pendingPostParams = useParams<{ accountCommentIndex?: string }>();
|
||||
const pendingPostCommentIndex = pendingPostParams?.accountCommentIndex ? parseInt(pendingPostParams.accountCommentIndex, 10) : undefined;
|
||||
const pendingPost = useAccountComment({ commentIndex: pendingPostCommentIndex });
|
||||
const pendingPost = useSafeAccountComment({ commentIndex: pendingPostCommentIndex });
|
||||
const pendingPostCommunityAddress =
|
||||
(pendingPost as { communityAddress?: string }).communityAddress ||
|
||||
// compatibility fallback for legacy inbound/persisted comment payloads
|
||||
|
||||
@@ -22,6 +22,7 @@ type TestComment = {
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: { subscriptions: [] as string[] },
|
||||
accountComments: [] as TestComment[],
|
||||
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
||||
accountCommunityAddresses: [] as string[],
|
||||
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
||||
directoryByAddress: {
|
||||
@@ -64,9 +65,36 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const getScopedAccountComments = (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => {
|
||||
let scopedComments = [...testState.accountComments];
|
||||
|
||||
if (options?.commentIndices?.length) {
|
||||
const normalizedCommentIndices = options.commentIndices.filter((commentIndex) => Number.isInteger(commentIndex) && commentIndex >= 0);
|
||||
scopedComments = normalizedCommentIndices.map((commentIndex) => testState.accountComments[commentIndex]).filter(Boolean) as TestComment[];
|
||||
} else if (options?.communityAddress) {
|
||||
scopedComments = scopedComments.filter(
|
||||
(comment) => (comment.communityAddress || (comment as TestComment & { subplebbitAddress?: string }).subplebbitAddress) === options.communityAddress,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof options?.newerThan === 'number') {
|
||||
const newerThanTimestamp = Math.floor(Date.now() / 1000) - options.newerThan;
|
||||
scopedComments = scopedComments.filter((comment) => (comment.timestamp || 0) > newerThanTimestamp);
|
||||
}
|
||||
|
||||
if (options?.sortType === 'new') {
|
||||
scopedComments = [...scopedComments].reverse();
|
||||
}
|
||||
|
||||
return scopedComments;
|
||||
};
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => testState.account,
|
||||
useAccountComments: () => ({ accountComments: testState.accountComments }),
|
||||
useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => {
|
||||
testState.accountCommentsCalls.push(options);
|
||||
return { accountComments: getScopedAccountComments(options) };
|
||||
},
|
||||
useFeed: () => ({
|
||||
feed: testState.feed,
|
||||
hasMore: testState.hasMore,
|
||||
@@ -236,6 +264,7 @@ describe('Board', () => {
|
||||
latestLocation = '';
|
||||
testState.account = { subscriptions: [] };
|
||||
testState.accountComments = [];
|
||||
testState.accountCommentsCalls = [];
|
||||
testState.accountCommunityAddresses = [];
|
||||
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
|
||||
testState.directoryByAddress = {
|
||||
@@ -302,6 +331,13 @@ describe('Board', () => {
|
||||
communityAddress: 'music-posting.eth',
|
||||
timestamp: currentTimestamp,
|
||||
},
|
||||
{
|
||||
cid: 'fresh-reply',
|
||||
postCid: 'another-post',
|
||||
state: 'succeeded',
|
||||
communityAddress: 'music-posting.eth',
|
||||
timestamp: currentTimestamp,
|
||||
},
|
||||
];
|
||||
testState.hasMore = true;
|
||||
|
||||
@@ -309,6 +345,11 @@ describe('Board', () => {
|
||||
|
||||
expect(document.title).toBe('/mu/ - 5chan');
|
||||
expect(testState.setResetFunctionMock).toHaveBeenCalledWith(testState.resetMock);
|
||||
expect(testState.accountCommentsCalls).toContainEqual({
|
||||
communityAddress: 'music-posting.eth',
|
||||
newerThan: 3600,
|
||||
sortType: 'old',
|
||||
});
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pinned-post', 'fresh-post']);
|
||||
expect(container.querySelector('[data-testid="board-pagination"]')?.textContent).toBe('/mu:1:2');
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ import { PageFooterDesktop, PageFooterMobile } from '../../components/footer';
|
||||
import { Post } from '../post';
|
||||
|
||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
|
||||
// Keep the hook on its indexed fast path when this view should not inject local posts.
|
||||
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
|
||||
|
||||
/** Board feed always uses 'active' sort; catalog dropdown does not affect board ordering. */
|
||||
const BOARD_SORT_TYPE = 'active' as const;
|
||||
@@ -160,7 +163,18 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
||||
);
|
||||
|
||||
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
|
||||
const { accountComments } = useAccountComments();
|
||||
const accountCommentLookupOptions = useMemo(
|
||||
() =>
|
||||
communityAddress
|
||||
? {
|
||||
communityAddress,
|
||||
newerThan: RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS,
|
||||
sortType: 'old' as const,
|
||||
}
|
||||
: EMPTY_ACCOUNT_COMMENT_LOOKUP,
|
||||
[communityAddress],
|
||||
);
|
||||
const { accountComments: recentAccountComments } = useAccountComments(accountCommentLookupOptions);
|
||||
|
||||
const pathWithoutSettings = location.pathname.replace(/\/settings$/, '');
|
||||
const currentPage = getPageFromFeedPath(pathWithoutSettings);
|
||||
@@ -179,13 +193,13 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
||||
const feedCids = useMemo(() => new Set(feed.map((f) => f.cid)), [feed]);
|
||||
const filteredComments = useMemo(
|
||||
() =>
|
||||
accountComments.filter((comment) => {
|
||||
recentAccountComments.filter((comment) => {
|
||||
const { cid, deleted, postCid, removed, state, timestamp } = comment || {};
|
||||
const commentCommunityAddress = comment?.communityAddress || comment?.subplebbitAddress;
|
||||
return (
|
||||
!deleted &&
|
||||
!removed &&
|
||||
timestamp > Date.now() / 1000 - 60 * 60 &&
|
||||
timestamp > Date.now() / 1000 - RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS &&
|
||||
state === 'succeeded' &&
|
||||
cid &&
|
||||
cid === postCid &&
|
||||
@@ -193,7 +207,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
||||
!feedCids.has(cid)
|
||||
);
|
||||
}),
|
||||
[accountComments, communityAddress, feedCids],
|
||||
[recentAccountComments, communityAddress, feedCids],
|
||||
);
|
||||
|
||||
// show newest account comment at the top of the feed but after pinned posts
|
||||
|
||||
@@ -34,6 +34,7 @@ type FilterItem = {
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: { subscriptions: [] as string[] },
|
||||
accountComments: [] as TestComment[],
|
||||
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
||||
clearMatchedFiltersMock: vi.fn(),
|
||||
directoryByAddress: {
|
||||
'music-posting.eth': {
|
||||
@@ -60,6 +61,7 @@ const testState = vi.hoisted(() => ({
|
||||
setMatchedFilterMock: vi.fn(),
|
||||
setResetFunctionMock: vi.fn(),
|
||||
sortType: 'new' as 'active' | 'new',
|
||||
windowWidth: 900,
|
||||
community: {
|
||||
error: undefined as Error | undefined,
|
||||
shortAddress: 'music-posting.eth',
|
||||
@@ -92,9 +94,36 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const getScopedAccountComments = (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => {
|
||||
let scopedComments = [...testState.accountComments];
|
||||
|
||||
if (options?.commentIndices?.length) {
|
||||
const normalizedCommentIndices = options.commentIndices.filter((commentIndex) => Number.isInteger(commentIndex) && commentIndex >= 0);
|
||||
scopedComments = normalizedCommentIndices.map((commentIndex) => testState.accountComments[commentIndex]).filter(Boolean) as TestComment[];
|
||||
} else if (options?.communityAddress) {
|
||||
scopedComments = scopedComments.filter(
|
||||
(comment) => (comment.communityAddress || (comment as TestComment & { subplebbitAddress?: string }).subplebbitAddress) === options.communityAddress,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof options?.newerThan === 'number') {
|
||||
const newerThanTimestamp = Math.floor(Date.now() / 1000) - options.newerThan;
|
||||
scopedComments = scopedComments.filter((comment) => (comment.timestamp || 0) > newerThanTimestamp);
|
||||
}
|
||||
|
||||
if (options?.sortType === 'new') {
|
||||
scopedComments = [...scopedComments].reverse();
|
||||
}
|
||||
|
||||
return scopedComments;
|
||||
};
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => testState.account,
|
||||
useAccountComments: () => ({ accountComments: testState.accountComments }),
|
||||
useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => {
|
||||
testState.accountCommentsCalls.push(options);
|
||||
return { accountComments: getScopedAccountComments(options) };
|
||||
},
|
||||
useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean } }) => ({
|
||||
feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed,
|
||||
hasMore: testState.hasMore,
|
||||
@@ -135,10 +164,6 @@ vi.mock('react-virtuoso', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-catalog-feed-rows', () => ({
|
||||
default: (_columnCount: number, processedFeed: TestComment[]) => processedFeed.map((comment) => [comment]),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined),
|
||||
@@ -161,7 +186,7 @@ vi.mock('../../../hooks/use-state-string', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-window-width', () => ({
|
||||
default: () => 900,
|
||||
default: () => testState.windowWidth,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-catalog-style-store', () => ({
|
||||
@@ -271,6 +296,7 @@ describe('Catalog', () => {
|
||||
latestLocation = '';
|
||||
testState.account = { subscriptions: [] };
|
||||
testState.accountComments = [];
|
||||
testState.accountCommentsCalls = [];
|
||||
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
|
||||
testState.directoryByAddress = {
|
||||
'music-posting.eth': {
|
||||
@@ -290,6 +316,7 @@ describe('Catalog', () => {
|
||||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||
testState.searchText = '';
|
||||
testState.sortType = 'new';
|
||||
testState.windowWidth = 900;
|
||||
testState.community = {
|
||||
error: undefined,
|
||||
shortAddress: 'music-posting.eth',
|
||||
@@ -331,7 +358,7 @@ describe('Catalog', () => {
|
||||
expect(document.title).toBe('/mu/ - catalog - 5chan');
|
||||
expect(testState.setCurrentCommunityAddressMock).toHaveBeenCalledWith('music-posting.eth');
|
||||
expect(testState.clearMatchedFiltersMock).toHaveBeenCalled();
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:top-post', 'row:boring-post']);
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:top-post,boring-post']);
|
||||
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(0, 'hidden-post', 'music-posting.eth');
|
||||
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(1, 'top-post', 'music-posting.eth');
|
||||
expect(testState.setMatchedFilterMock).toHaveBeenCalledWith('top-post', 'red');
|
||||
@@ -375,4 +402,51 @@ describe('Catalog', () => {
|
||||
expect(container.textContent).toContain('not_subscribed_to_any_board');
|
||||
expect(container.querySelector('[data-testid="catalog-first-row"]')?.textContent).toBe('music-posting.eth');
|
||||
});
|
||||
|
||||
it('queries scoped recent account posts and still applies local search filtering before injecting them', async () => {
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
testState.feed = [{ cid: 'network-post', title: 'cats on stage', communityAddress: 'music-posting.eth' }];
|
||||
testState.searchText = 'cats';
|
||||
testState.accountComments = [
|
||||
{
|
||||
cid: 'local-cats-post',
|
||||
content: 'cats local thread',
|
||||
postCid: 'local-cats-post',
|
||||
state: 'succeeded',
|
||||
communityAddress: 'music-posting.eth',
|
||||
timestamp: currentTimestamp,
|
||||
title: 'cats local',
|
||||
},
|
||||
{
|
||||
cid: 'local-dogs-post',
|
||||
content: 'dogs local thread',
|
||||
postCid: 'local-dogs-post',
|
||||
state: 'succeeded',
|
||||
communityAddress: 'music-posting.eth',
|
||||
timestamp: currentTimestamp,
|
||||
title: 'dogs local',
|
||||
},
|
||||
];
|
||||
|
||||
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
|
||||
|
||||
expect(testState.accountCommentsCalls).toContainEqual({
|
||||
communityAddress: 'music-posting.eth',
|
||||
newerThan: 3600,
|
||||
sortType: 'old',
|
||||
});
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:local-cats-post,network-post']);
|
||||
});
|
||||
|
||||
it('chunks catalog rows safely even when the viewport is narrower than one card', async () => {
|
||||
testState.windowWidth = 0;
|
||||
testState.feed = [
|
||||
{ cid: 'first-post', title: 'one', communityAddress: 'music-posting.eth' },
|
||||
{ cid: 'second-post', title: 'two', communityAddress: 'music-posting.eth' },
|
||||
];
|
||||
|
||||
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
|
||||
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:first-post', 'row:second-post']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||
import useCatalogFeedRows from '../../hooks/use-catalog-feed-rows';
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
|
||||
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
|
||||
@@ -27,6 +26,9 @@ import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||
import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort';
|
||||
|
||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
|
||||
// Keep the hook on its indexed fast path when this view should not inject local posts.
|
||||
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
|
||||
|
||||
interface CatalogFooterProps {
|
||||
communityAddresses: string[];
|
||||
@@ -281,7 +283,18 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
}, [communityAddresses, feedSortType, isMultiboard, paginationFeedPostsPerPage, filterItems, searchText, communityAddress, handleFilterMatch]);
|
||||
|
||||
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
|
||||
const { accountComments } = useAccountComments();
|
||||
const accountCommentLookupOptions = useMemo(
|
||||
() =>
|
||||
communityAddress
|
||||
? {
|
||||
communityAddress,
|
||||
newerThan: RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS,
|
||||
sortType: 'old' as const,
|
||||
}
|
||||
: EMPTY_ACCOUNT_COMMENT_LOOKUP,
|
||||
[communityAddress],
|
||||
);
|
||||
const { accountComments: recentAccountComments } = useAccountComments(accountCommentLookupOptions);
|
||||
|
||||
const resetTriggeredRef = useRef(false);
|
||||
|
||||
@@ -289,7 +302,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
const feedCids = useMemo(() => new Set(feed.map((f) => f.cid)), [feed]);
|
||||
const filteredComments = useMemo(
|
||||
() =>
|
||||
accountComments.filter((comment) => {
|
||||
recentAccountComments.filter((comment) => {
|
||||
const { cid, deleted, postCid, removed, state, timestamp } = comment || {};
|
||||
const commentCommunityAddress = comment?.communityAddress || comment?.subplebbitAddress;
|
||||
|
||||
@@ -297,7 +310,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
const basicConditions =
|
||||
!deleted &&
|
||||
!removed &&
|
||||
timestamp > Date.now() / 1000 - 60 * 60 &&
|
||||
timestamp > Date.now() / 1000 - RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS &&
|
||||
state === 'succeeded' &&
|
||||
cid &&
|
||||
cid === postCid &&
|
||||
@@ -315,7 +328,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
|
||||
return basicConditions;
|
||||
}),
|
||||
[accountComments, communityAddress, feedCids, searchText],
|
||||
[recentAccountComments, communityAddress, feedCids, searchText],
|
||||
);
|
||||
|
||||
// show newest account comment at the top of the feed but after pinned posts
|
||||
@@ -427,7 +440,18 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
return [...topPosts, ...regularPosts];
|
||||
}, [sortedFeed, filterItems]);
|
||||
|
||||
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, community);
|
||||
const rows = useMemo(() => {
|
||||
if (!isFeedLoaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const effectiveColumnCount = Math.max(columnCount, 1);
|
||||
const nextRows = [];
|
||||
for (let i = 0; i < processedFeed.length; i += effectiveColumnCount) {
|
||||
nextRows.push(processedFeed.slice(i, i + effectiveColumnCount));
|
||||
}
|
||||
return nextRows;
|
||||
}, [columnCount, isFeedLoaded, processedFeed]);
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`;
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('react-router-dom', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => undefined,
|
||||
useAccountComment: () => testState.post,
|
||||
useAccountComments: () => ({
|
||||
accountComments: testState.accountComments,
|
||||
@@ -117,6 +118,24 @@ describe('PendingPost', () => {
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true });
|
||||
});
|
||||
|
||||
it('redirects malformed pending indices to not found', async () => {
|
||||
testState.accountCommentIndex = '1abc';
|
||||
testState.accountComments = [{}, {}];
|
||||
|
||||
await renderPendingPost();
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true });
|
||||
});
|
||||
|
||||
it('redirects out-of-range pending indices to not found', async () => {
|
||||
testState.accountCommentIndex = '2';
|
||||
testState.accountComments = [{}, {}];
|
||||
|
||||
await renderPendingPost();
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true });
|
||||
});
|
||||
|
||||
it('redirects resolved pending posts to the canonical thread route', async () => {
|
||||
testState.accountCommentIndex = '1';
|
||||
testState.accountComments = [{}, {}];
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAccountComment, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { useDirectories } from '../../hooks/use-directories';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import { getBoardPath } from '../../lib/utils/route-utils';
|
||||
import { Post } from '../post';
|
||||
|
||||
const PendingPost = () => {
|
||||
const { accountComments } = useAccountComments();
|
||||
const { accountCommentIndex } = useParams<{ accountCommentIndex?: string }>();
|
||||
const commentIndex = accountCommentIndex ? parseInt(accountCommentIndex) : undefined;
|
||||
const post = useAccountComment({ commentIndex });
|
||||
const normalizedAccountCommentIndex = accountCommentIndex === undefined ? undefined : Number(accountCommentIndex);
|
||||
const hasNormalizedAccountCommentIndex = normalizedAccountCommentIndex !== undefined && !Number.isNaN(normalizedAccountCommentIndex);
|
||||
const post = useSafeAccountComment({ commentIndex: accountCommentIndex });
|
||||
const navigate = useNavigate();
|
||||
const directories = useDirectories();
|
||||
|
||||
@@ -17,10 +19,10 @@ const PendingPost = () => {
|
||||
|
||||
const isValidAccountCommentIndex =
|
||||
!accountCommentIndex ||
|
||||
(!isNaN(parseInt(accountCommentIndex)) &&
|
||||
parseInt(accountCommentIndex) >= 0 &&
|
||||
Number.isInteger(parseFloat(accountCommentIndex)) &&
|
||||
(accountComments?.length === 0 || parseInt(accountCommentIndex) <= accountComments.length));
|
||||
(hasNormalizedAccountCommentIndex &&
|
||||
normalizedAccountCommentIndex >= 0 &&
|
||||
Number.isInteger(normalizedAccountCommentIndex) &&
|
||||
(accountComments?.length === 0 || normalizedAccountCommentIndex < accountComments.length));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidAccountCommentIndex) {
|
||||
|
||||
Reference in New Issue
Block a user