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:
Tommaso Casaburi
2026-03-20 21:25:57 +08:00
committed by GitHub
parent 4ec74bc85e
commit c2e8e169c2
26 changed files with 499 additions and 136 deletions
+3 -2
View File
@@ -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,
}));
+2 -2
View File
@@ -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,
}));
+3 -3
View File
@@ -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);
+3 -2
View File
@@ -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';
+4 -3
View File
@@ -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;
+3 -2
View File
@@ -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 = (
<>
+30 -5
View File
@@ -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' }]);
});
});
-61
View File
@@ -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;
+9 -1
View File
@@ -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) {
+47
View File
@@ -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;
+2 -2
View File
@@ -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
+42 -1
View File
@@ -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');
+18 -4
View File
@@ -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
+81 -7
View File
@@ -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']);
});
});
+31 -7
View File
@@ -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 = [{}, {}];
+9 -7
View File
@@ -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) {