fix(post ids): fall back to comment cid for missing ids (#1167)

* fix(post ids): fall back to comment cid for missing ids

* fix(post ids): count cid-resolved account replies
This commit is contained in:
Tommaso Casaburi
2026-06-09 19:08:02 +07:00
committed by GitHub
parent 9de2e6a851
commit c0f1a77958
8 changed files with 208 additions and 68 deletions
@@ -47,8 +47,10 @@ type TestComment = {
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
addChallengeMock: vi.fn(), addChallengeMock: vi.fn(),
accountCommentsByCid: {} as Record<string, TestComment | undefined>,
hasMoreReplies: false, hasMoreReplies: false,
openReplyModalMock: vi.fn(), openReplyModalMock: vi.fn(),
pseudonymityMode: 'none',
replyComments: [] as Array<TestComment | undefined>, replyComments: [] as Array<TestComment | undefined>,
setResetFunctionMock: vi.fn(), setResetFunctionMock: vi.fn(),
virtuosoProps: [] as Array<{ defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: unknown }>, virtuosoProps: [] as Array<{ defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: unknown }>,
@@ -82,8 +84,8 @@ vi.mock('react-i18next', () => ({
})); }));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => ({ author: { address: '0xviewer' } }), useAccount: () => ({ id: 'viewer-account', author: { address: '0xviewer' } }),
useAccountComment: () => undefined, useAccountComment: (options?: { commentCid?: string }) => (options?.commentCid ? testState.accountCommentsByCid[options.commentCid] : undefined),
useEditedComment: () => ({ editedComment: undefined }), useEditedComment: () => ({ editedComment: undefined }),
usePublishCommentModeration: () => ({ usePublishCommentModeration: () => ({
error: undefined, error: undefined,
@@ -137,7 +139,12 @@ vi.mock('react-virtuoso', () => ({
})); }));
vi.mock('../../lib/get-short-address', () => ({ vi.mock('../../lib/get-short-address', () => ({
default: (value?: string) => (value ? value.slice(0, 4) : ''), default: (value?: string) => {
if (!value) return '';
if (value.includes('.')) return value;
if (value.length < 20) return '';
return value.slice(8, 20);
},
})); }));
vi.mock('../../views/post/post.module.css', () => ({ vi.mock('../../views/post/post.module.css', () => ({
@@ -247,14 +254,14 @@ vi.mock('../../hooks/use-current-time', () => ({
})); }));
vi.mock('../../hooks/use-board-pseudonymity-mode', () => ({ vi.mock('../../hooks/use-board-pseudonymity-mode', () => ({
useBoardPseudonymityMode: () => 'none', useBoardPseudonymityMode: () => testState.pseudonymityMode,
})); }));
vi.mock('../comment-content', () => ({ vi.mock('../comment-content/comment-content', () => ({
default: ({ comment }: { comment?: TestComment }) => createElement('div', { 'data-testid': 'comment-content' }, comment?.cid ?? 'missing'), default: ({ comment }: { comment?: TestComment }) => createElement('div', { 'data-testid': 'comment-content' }, comment?.cid ?? 'missing'),
})); }));
vi.mock('../comment-media', () => ({ vi.mock('../comment-media/comment-media', () => ({
default: () => createElement('div', { 'data-testid': 'comment-media' }, 'media'), default: () => createElement('div', { 'data-testid': 'comment-media' }, 'media'),
})); }));
@@ -266,24 +273,24 @@ vi.mock('../failed-publish-notice', () => ({
default: () => createElement('div', { 'data-testid': 'failed-publish-notice' }, 'failed-publish-notice'), default: () => createElement('div', { 'data-testid': 'failed-publish-notice' }, 'failed-publish-notice'),
})); }));
vi.mock('../embed', () => ({ vi.mock('../embed/embed-utils', () => ({
canEmbed: () => false, canEmbed: () => false,
})); }));
vi.mock('../loading-ellipsis', () => ({ vi.mock('../loading-ellipsis/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string), default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
})); }));
vi.mock('../post-desktop/post-menu-desktop', () => ({ vi.mock('../post-desktop/post-menu-desktop/post-menu-desktop', () => ({
default: ({ postMenu }: { postMenu: { communityAddress?: string } }) => default: ({ postMenu }: { postMenu: { communityAddress?: string } }) =>
createElement('div', { 'data-testid': 'post-menu-desktop' }, postMenu.communityAddress ?? 'missing'), createElement('div', { 'data-testid': 'post-menu-desktop' }, postMenu.communityAddress ?? 'missing'),
})); }));
vi.mock('../reply-quote-preview', () => ({ vi.mock('../reply-quote-preview/reply-quote-preview', () => ({
default: ({ backlinkReply }: { backlinkReply?: TestComment }) => createElement('div', { 'data-testid': 'reply-quote-preview' }, backlinkReply?.cid ?? 'missing'), default: ({ backlinkReply }: { backlinkReply?: TestComment }) => createElement('div', { 'data-testid': 'reply-quote-preview' }, backlinkReply?.cid ?? 'missing'),
})); }));
vi.mock('../tooltip', () => ({ vi.mock('../tooltip/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children), default: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children),
})); }));
@@ -371,7 +378,7 @@ vi.mock('../../hooks/use-delete-failed-post', () => ({
}), }),
})); }));
vi.mock('../post-mobile/post-menu-mobile', () => ({ vi.mock('../post-mobile/post-menu-mobile/post-menu-mobile', () => ({
default: ({ postMenu }: { postMenu: { communityAddress?: string } }) => default: ({ postMenu }: { postMenu: { communityAddress?: string } }) =>
createElement('div', { 'data-testid': 'post-menu-mobile' }, postMenu.communityAddress ?? 'missing'), createElement('div', { 'data-testid': 'post-menu-mobile' }, postMenu.communityAddress ?? 'missing'),
})); }));
@@ -449,7 +456,9 @@ const makeLegacyThreadWithoutReplies = (): TestComment => ({
describe('post community address compatibility', () => { describe('post community address compatibility', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
testState.accountCommentsByCid = {};
testState.hasMoreReplies = false; testState.hasMoreReplies = false;
testState.pseudonymityMode = 'none';
testState.replyComments = []; testState.replyComments = [];
testState.virtuosoProps = []; testState.virtuosoProps = [];
@@ -505,6 +514,58 @@ describe('post community address compatibility', () => {
expect(container.querySelector('.capcodeAdminIcon')).toBeTruthy(); expect(container.querySelector('.capcodeAdminIcon')).toBeTruthy();
}); });
it('falls back to author shortAddress when the full author address cannot be shortened', async () => {
testState.pseudonymityMode = 'per-post';
const post = {
...makeLegacyThreadWithoutReplies(),
author: { address: 'short-address', shortAddress: 'B2mAZojE' },
};
await renderWithRoute(createElement(PostDesktop, { post } as any), '/mu/thread/post-1');
expect(container.textContent).toContain('ID: B2mAZojE');
await renderWithRoute(createElement(PostMobile, { post } as any), '/mu/thread/post-1');
expect(container.textContent).toContain('ID: B2mAZojE');
});
it('uses safe account reply data by cid when a reply has no index', async () => {
testState.pseudonymityMode = 'per-post';
const post = makeLegacyThread();
const reply = post.replies?.pages?.new?.comments?.[0];
if (!reply?.cid) {
throw new Error('missing fixture reply');
}
reply.index = undefined;
reply.author = {};
testState.accountCommentsByCid[reply.cid] = {
...reply,
author: { shortAddress: 'ReplyKid9' },
};
await renderWithRoute(createElement(PostDesktop, { post, showAllReplies: true }), '/mu/thread/post-1');
expect(container.textContent).toContain('ID: ReplyKid');
await renderWithRoute(createElement(PostMobile, { post, showAllReplies: true }), '/mu/thread/post-1');
expect(container.textContent).toContain('ID: ReplyKid');
});
it('falls back to the reply cid when published reply author metadata is missing', async () => {
testState.pseudonymityMode = 'per-post';
const post = makeLegacyThread();
const reply = post.replies?.pages?.new?.comments?.[0];
if (!reply) {
throw new Error('missing fixture reply');
}
reply.cid = 'Qmb4NxbRDVVJF7w9QtwXuY94jqGAAx7Thx3JPuofDVPKY1';
reply.author = {};
await renderWithRoute(createElement(PostDesktop, { post, showAllReplies: true }), '/mu/thread/post-1');
expect(container.textContent).toContain('ID: Qmb4NxbR');
await renderWithRoute(createElement(PostMobile, { post, showAllReplies: true }), '/mu/thread/post-1');
expect(container.textContent).toContain('ID: Qmb4NxbR');
});
it('forwards Pretext-backed reply estimates into Virtuoso for desktop and mobile thread views', async () => { it('forwards Pretext-backed reply estimates into Virtuoso for desktop and mobile thread views', async () => {
testState.hasMoreReplies = true; testState.hasMoreReplies = true;
+21 -16
View File
@@ -25,17 +25,17 @@ import useScrollToReply from '../../hooks/use-scroll-to-reply';
import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import useSafeAccountComment from '../../hooks/use-safe-account-comment';
import { useCurrentTime } from '../../hooks/use-current-time'; import { useCurrentTime } from '../../hooks/use-current-time';
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode'; import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
import CommentContent from '../comment-content'; import CommentContent from '../comment-content/comment-content';
import CommentMedia from '../comment-media'; import CommentMedia from '../comment-media/comment-media';
import EditMenu from '../edit-menu/edit-menu'; import EditMenu from '../edit-menu/edit-menu';
import FailedPublishNotice from '../failed-publish-notice'; import FailedPublishNotice from '../failed-publish-notice';
import { canEmbed } from '../embed'; import { canEmbed } from '../embed/embed-utils';
import LoadingEllipsis from '../loading-ellipsis'; import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import PostAuthorFlags from '../post-author-flags'; import PostAuthorFlags from '../post-author-flags';
import PostFlashTag from '../post-flash-tag'; import PostFlashTag from '../post-flash-tag';
import PostMenuDesktop from './post-menu-desktop'; import PostMenuDesktop from './post-menu-desktop/post-menu-desktop';
import ReplyQuotePreview from '../reply-quote-preview'; import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import Tooltip from '../tooltip'; import Tooltip from '../tooltip/tooltip';
import TimeAgoTooltip from '../time-ago-tooltip'; import TimeAgoTooltip from '../time-ago-tooltip';
import { PostProps } from '../../views/post/post'; import { PostProps } from '../../views/post/post';
import { create } from 'zustand'; import { create } from 'zustand';
@@ -69,6 +69,7 @@ import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../l
import useDeleteFailedPost from '../../hooks/use-delete-failed-post'; import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts'; import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getCommentUserID } from '../../lib/utils/comment-user-id-utils';
import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates'; import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates';
import { getAuthorBadge } from '../../lib/utils/author-display-utils'; import { getAuthorBadge } from '../../lib/utils/author-display-utils';
import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection'; import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection';
@@ -233,7 +234,7 @@ const PostInfo = ({
const archived = isCommentArchived(post); const archived = isCommentArchived(post);
const purged = post?.commentModeration?.purged; const purged = post?.commentModeration?.purged;
const title = post?.title?.trim(); const title = post?.title?.trim();
const { address, shortAddress } = author || {}; const { address } = author || {};
const displayName = author?.displayName?.trim(); const displayName = author?.displayName?.trim();
const authorBadge = getAuthorBadge({ address, role: roles?.[address]?.role }); const authorBadge = getAuthorBadge({ address, role: roles?.[address]?.role });
const hasFailedState = state === 'failed'; const hasFailedState = state === 'failed';
@@ -268,7 +269,7 @@ const PostInfo = ({
const alertThresholdSeconds = getAlertThresholdSeconds(); const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds; const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds;
const userID = address ? getShortAddress(address) : shortAddress; const userID = getCommentUserID(post);
const userIDBackgroundColor = hashStringToColor(userID); const userIDBackgroundColor = hashStringToColor(userID);
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor); const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
@@ -277,11 +278,11 @@ const PostInfo = ({
const handleUserAddressClick = useAuthorAddressClick(); const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = (() => { const numberOfPostsByAuthor = (() => {
if (!showUserID || deleted || removed || purged || !shortAddress || !postCid) { if (!showUserID || deleted || removed || purged || !userID || !postCid) {
return 0; return 0;
} }
return Math.max(postsByAuthorInThread?.get(shortAddress) ?? 0, 1); return Math.max(postsByAuthorInThread?.get(userID) ?? 0, 1);
})(); })();
const { hidden } = useHide({ cid: cid || '' }); const { hidden } = useHide({ cid: cid || '' });
@@ -756,10 +757,12 @@ const Reply = ({
disableDeferredLayout, disableDeferredLayout,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number>; disableDeferredLayout?: boolean }) => { }: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number>; disableDeferredLayout?: boolean }) => {
const accountReply = useSafeAccountComment({ const accountReply = useSafeAccountComment({
commentCid: reply?.cid,
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined, commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
}); });
const hasReplyIndex = typeof reply?.index === 'number'; const hasReplyIndex = typeof reply?.index === 'number';
let post = hasReplyIndex && accountReply?.index === reply.index ? accountReply : reply; const isAccountReply = (hasReplyIndex && accountReply?.index === reply.index) || (!!reply?.cid && accountReply?.cid === reply.cid);
let post = isAccountReply ? accountReply : reply;
// handle pending mod or author edit // handle pending mod or author edit
const { editedComment } = useEditedComment({ comment: post }); const { editedComment } = useEditedComment({ comment: post });
if (editedComment) { if (editedComment) {
@@ -767,7 +770,8 @@ const Reply = ({
} }
post = withResolvedCommentCommunityAddress(post); post = withResolvedCommentCommunityAddress(post);
const { author, cid, deleted, link, linkHeight, linkWidth, postCid, reason, removed, spoiler, communityAddress, thumbnailUrl, parentCid } = post || {}; const { cid, deleted, link, linkHeight, linkWidth, postCid, reason, removed, spoiler, communityAddress, thumbnailUrl, parentCid } = post || {};
const userID = getCommentUserID(post);
const purged = post?.commentModeration?.purged; const purged = post?.commentModeration?.purged;
const directories = useDirectories(); const directories = useDirectories();
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
@@ -797,7 +801,7 @@ const Reply = ({
return ( return (
<div className={`${styles.replyDesktop} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}> <div className={`${styles.replyDesktop} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
<div className={styles.sideArrows}>{'>>'}</div> <div className={styles.sideArrows}>{'>>'}</div>
<div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}> <div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={userID} data-post-cid={postCid}>
<PostInfo <PostInfo
post={post} post={post}
postReplyCount={postReplyCount} postReplyCount={postReplyCount}
@@ -852,7 +856,8 @@ const PostDesktop = ({
}: PostProps) => { }: PostProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const resolvedPost = withResolvedCommentCommunityAddress(post); const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, content, deleted, link, linkHeight, linkWidth, postCid, removed, spoiler, state, communityAddress, thumbnailUrl, parentCid } = resolvedPost || {}; const { cid, content, deleted, link, linkHeight, linkWidth, postCid, removed, spoiler, state, communityAddress, thumbnailUrl, parentCid } = resolvedPost || {};
const userID = getCommentUserID(resolvedPost);
const purged = resolvedPost?.commentModeration?.purged; const purged = resolvedPost?.commentModeration?.purged;
const params = useParams(); const params = useParams();
const location = useLocation(); const location = useLocation();
@@ -1159,7 +1164,7 @@ const PostDesktop = ({
<div <div
data-thread-container-cid={cid} data-thread-container-cid={cid}
data-cid={cid} data-cid={cid}
data-author-address={author?.shortAddress} data-author-address={userID}
data-post-cid={postCid} data-post-cid={postCid}
className={`${styles.opContainer} ${shouldShowSnow() && hasThumbnail ? styles.xmasHatWrapper : ''}`} className={`${styles.opContainer} ${shouldShowSnow() && hasThumbnail ? styles.xmasHatWrapper : ''}`}
> >
+20 -20
View File
@@ -24,15 +24,15 @@ import useScrollToReply from '../../hooks/use-scroll-to-reply';
import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import useSafeAccountComment from '../../hooks/use-safe-account-comment';
import { useCurrentTime } from '../../hooks/use-current-time'; import { useCurrentTime } from '../../hooks/use-current-time';
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode'; import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
import CommentContent from '../comment-content'; import CommentContent from '../comment-content/comment-content';
import CommentMedia, { MediaLoadFailureInfo } from '../comment-media'; import CommentMedia, { MediaLoadFailureInfo } from '../comment-media/comment-media';
import FailedPublishNotice from '../failed-publish-notice'; import FailedPublishNotice from '../failed-publish-notice';
import LoadingEllipsis from '../loading-ellipsis'; import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
import PostAuthorFlags from '../post-author-flags'; import PostAuthorFlags from '../post-author-flags';
import PostFlashTag from '../post-flash-tag'; import PostFlashTag from '../post-flash-tag';
import PostMenuMobile from './post-menu-mobile'; import PostMenuMobile from './post-menu-mobile/post-menu-mobile';
import ReplyQuotePreview from '../reply-quote-preview'; import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import Tooltip from '../tooltip'; import Tooltip from '../tooltip/tooltip';
import TimeAgoTooltip from '../time-ago-tooltip'; import TimeAgoTooltip from '../time-ago-tooltip';
import { PostProps } from '../../views/post/post'; import { PostProps } from '../../views/post/post';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
@@ -58,6 +58,7 @@ import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../l
import useDeleteFailedPost from '../../hooks/use-delete-failed-post'; import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts'; import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getCommentUserID } from '../../lib/utils/comment-user-id-utils';
import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates'; import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates';
import { getAuthorBadge } from '../../lib/utils/author-display-utils'; import { getAuthorBadge } from '../../lib/utils/author-display-utils';
import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection'; import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection';
@@ -100,7 +101,7 @@ const PostInfoAndMedia = ({
: undefined; : undefined;
const isReply = parentCid; const isReply = parentCid;
const title = post?.title?.trim(); const title = post?.title?.trim();
const { address, shortAddress } = author || {}; const { address } = author || {};
const displayName = author?.displayName?.trim(); const displayName = author?.displayName?.trim();
const authorBadge = getAuthorBadge({ address, role: roles?.[address]?.role }); const authorBadge = getAuthorBadge({ address, role: roles?.[address]?.role });
@@ -220,17 +221,17 @@ const PostInfoAndMedia = ({
const pseudonymityMode = useBoardPseudonymityMode(communityAddress); const pseudonymityMode = useBoardPseudonymityMode(communityAddress);
const showUserID = pseudonymityMode === 'per-post'; const showUserID = pseudonymityMode === 'per-post';
const userID = getCommentUserID(resolvedPost);
const handleUserAddressClick = useAuthorAddressClick(); const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = (() => { const numberOfPostsByAuthor = (() => {
if (!showUserID || deleted || removed || purged || !shortAddress || !postCid) { if (!showUserID || deleted || removed || purged || !userID || !postCid) {
return 0; return 0;
} }
return Math.max(postsByAuthorInThread?.get(shortAddress) ?? 0, 1); return Math.max(postsByAuthorInThread?.get(userID) ?? 0, 1);
})(); })();
const userID = address ? getShortAddress(address) : shortAddress;
const userIDBackgroundColor = hashStringToColor(userID); const userIDBackgroundColor = hashStringToColor(userID);
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor); const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
@@ -532,17 +533,20 @@ const Reply = ({
disableDeferredLayout, disableDeferredLayout,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number>; disableDeferredLayout?: boolean }) => { }: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number>; disableDeferredLayout?: boolean }) => {
const accountReply = useSafeAccountComment({ const accountReply = useSafeAccountComment({
commentCid: reply?.cid,
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined, commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
}); });
const hasReplyIndex = typeof reply?.index === 'number'; const hasReplyIndex = typeof reply?.index === 'number';
let post = hasReplyIndex && accountReply?.index === reply.index ? accountReply : reply; const isAccountReply = (hasReplyIndex && accountReply?.index === reply.index) || (!!reply?.cid && accountReply?.cid === reply.cid);
let post = isAccountReply ? accountReply : reply;
// handle pending mod or author edit // handle pending mod or author edit
const { editedComment } = useEditedComment({ comment: post }); const { editedComment } = useEditedComment({ comment: post });
if (editedComment) { if (editedComment) {
post = editedComment; post = editedComment;
} }
post = withResolvedCommentCommunityAddress(post); post = withResolvedCommentCommunityAddress(post);
const { author, cid, deleted, postCid, reason, removed, communityAddress } = post || {}; const { cid, deleted, postCid, reason, removed, communityAddress } = post || {};
const userID = getCommentUserID(post);
const purged = post?.commentModeration?.purged; const purged = post?.commentModeration?.purged;
const directories = useDirectories(); const directories = useDirectories();
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
@@ -565,12 +569,7 @@ const Reply = ({
return ( return (
<div className={`${styles.replyMobile} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}> <div className={`${styles.replyMobile} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
<div className={styles.reply}> <div className={styles.reply}>
<div <div className={`${styles.replyContainer} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={userID} data-post-cid={postCid}>
className={`${styles.replyContainer} ${isRouteLinkToReply && styles.highlight}`}
data-cid={cid}
data-author-address={author?.shortAddress}
data-post-cid={postCid}
>
<PostInfoAndMedia <PostInfoAndMedia
onMediaLoadFailureChange={setFailedMediaUrl} onMediaLoadFailureChange={setFailedMediaUrl}
post={post} post={post}
@@ -608,7 +607,8 @@ const PostMobile = ({
}: PostProps) => { }: PostProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const resolvedPost = withResolvedCommentCommunityAddress(post); const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, parentCid, postCid, replyCount, state, communityAddress } = resolvedPost || {}; const { cid, parentCid, postCid, replyCount, state, communityAddress } = resolvedPost || {};
const userID = getCommentUserID(resolvedPost);
const params = useParams(); const params = useParams();
const location = useLocation(); const location = useLocation();
const navigationType = useNavigationType(); const navigationType = useNavigationType();
@@ -866,7 +866,7 @@ const PostMobile = ({
className={`${styles.postOp} ${shouldShowSnow() ? styles.xmasHatWrapper : ''}`} className={`${styles.postOp} ${shouldShowSnow() ? styles.xmasHatWrapper : ''}`}
data-thread-container-cid={cid} data-thread-container-cid={cid}
data-cid={cid} data-cid={cid}
data-author-address={author?.shortAddress} data-author-address={userID}
data-post-cid={postCid} data-post-cid={postCid}
> >
{shouldShowSnow() && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />} {shouldShowSnow() && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />}
+41 -10
View File
@@ -18,27 +18,26 @@ type TestComment = {
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
accountComments: [] as TestComment[], accountComments: [] as TestComment[],
accountCommentsCalls: [] as Array<{ commentIndices?: number[] } | undefined>, accountCommentsCalls: [] as Array<{ commentIndices?: number[]; filter?: (comment: TestComment) => boolean } | undefined>,
replies: [] as TestComment[], replies: [] as TestComment[],
})); }));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccountComments: (options?: { commentIndices?: number[] }) => { useAccountComments: (options?: { commentIndices?: number[]; filter?: (comment: TestComment) => boolean }) => {
testState.accountCommentsCalls.push(options); testState.accountCommentsCalls.push(options);
if (!options?.commentIndices?.length) { if (!options?.commentIndices?.length) {
return { const accountComments = options?.filter ? testState.accountComments.filter(options.filter) : testState.accountComments;
accountComments: testState.accountComments, return { accountComments };
};
} }
const normalizedCommentIndices = options.commentIndices.filter((commentIndex) => Number.isInteger(commentIndex) && commentIndex >= 0); const normalizedCommentIndices = options.commentIndices.filter((commentIndex) => Number.isInteger(commentIndex) && commentIndex >= 0);
return { const accountComments = normalizedCommentIndices
accountComments: normalizedCommentIndices .map((commentIndex) => testState.accountComments.find((accountComment) => accountComment.index === commentIndex))
.map((commentIndex) => testState.accountComments.find((accountComment) => accountComment.index === commentIndex)) .filter(Boolean);
.filter(Boolean),
}; return { accountComments };
}, },
})); }));
@@ -146,6 +145,38 @@ describe('useFreshReplies', () => {
expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [0] }); expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [0] });
}); });
it('replaces unindexed replies with account comments matched by cid', () => {
testState.replies = [
{
cid: 'reply-cid',
content: 'stale reply',
communityAddress: 'music.eth',
},
{
cid: 'network-reply-cid',
content: 'network reply',
communityAddress: 'music.eth',
},
];
testState.accountComments = [
{
cid: 'reply-cid',
content: 'fresh reply',
index: 5,
number: 27,
communityAddress: 'music.eth',
},
];
renderHook();
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: [-1] });
expect(testState.accountCommentsCalls).toContainEqual({ filter: expect.any(Function) });
});
it('orders replies by final post number after a pending reply is approved', () => { it('orders replies by final post number after a pending reply is approved', () => {
testState.replies = [ testState.replies = [
{ {
+39 -5
View File
@@ -10,29 +10,63 @@ const useFreshReplies = (replies: Comment[] = []) => {
() => Array.from(new Set(replies.map((reply) => reply?.index).filter((replyIndex): replyIndex is number => typeof replyIndex === 'number'))), () => Array.from(new Set(replies.map((reply) => reply?.index).filter((replyIndex): replyIndex is number => typeof replyIndex === 'number'))),
[replies], [replies],
); );
const replyCidsWithoutIndices = useMemo(
() =>
Array.from(
new Set(
replies
.map((reply) => (typeof reply?.index === 'number' ? undefined : reply?.cid))
.filter((replyCid): replyCid is string => typeof replyCid === 'string' && replyCid.length > 0),
),
),
[replies],
);
const accountCommentLookupOptions = useMemo(() => (replyIndices.length > 0 ? { commentIndices: replyIndices } : EMPTY_ACCOUNT_COMMENT_LOOKUP), [replyIndices]); const accountCommentLookupOptions = useMemo(() => (replyIndices.length > 0 ? { commentIndices: replyIndices } : EMPTY_ACCOUNT_COMMENT_LOOKUP), [replyIndices]);
const { accountComments } = useAccountComments(accountCommentLookupOptions); const accountCommentCidLookupOptions = useMemo(() => {
if (replyCidsWithoutIndices.length === 0) {
return EMPTY_ACCOUNT_COMMENT_LOOKUP;
}
const replyCidSet = new Set(replyCidsWithoutIndices);
return {
filter: (accountComment: Comment) => typeof accountComment?.cid === 'string' && replyCidSet.has(accountComment.cid),
};
}, [replyCidsWithoutIndices]);
const { accountComments: accountCommentsByIndexList } = useAccountComments(accountCommentLookupOptions);
const { accountComments: accountCommentsByCidList } = useAccountComments(accountCommentCidLookupOptions);
return useMemo(() => { return useMemo(() => {
if (!replies.length) { if (!replies.length) {
return replies; return replies;
} }
if (!accountComments?.length) { if (!accountCommentsByIndexList?.length && !accountCommentsByCidList?.length) {
return sortRepliesForDisplay(replies); return sortRepliesForDisplay(replies);
} }
const accountCommentsByIndex = new Map<number, Comment>(); const accountCommentsByIndex = new Map<number, Comment>();
for (const accountComment of accountComments) { for (const accountComment of accountCommentsByIndexList) {
if (typeof accountComment?.index === 'number') { if (typeof accountComment?.index === 'number') {
accountCommentsByIndex.set(accountComment.index, accountComment); accountCommentsByIndex.set(accountComment.index, accountComment);
} }
} }
const accountCommentsByCidMap = new Map<string, Comment>();
for (const accountComment of accountCommentsByCidList) {
if (typeof accountComment?.cid === 'string') {
accountCommentsByCidMap.set(accountComment.cid, accountComment);
}
}
let hasFreshReplies = false; let hasFreshReplies = false;
const nextReplies = replies.map((reply) => { const nextReplies = replies.map((reply) => {
if (typeof reply?.index !== 'number') { if (typeof reply?.index !== 'number') {
return reply; const freshReply = typeof reply?.cid === 'string' ? accountCommentsByCidMap.get(reply.cid) : undefined;
if (!freshReply) {
return reply;
}
hasFreshReplies = true;
return freshReply;
} }
const freshReply = accountCommentsByIndex.get(reply.index); const freshReply = accountCommentsByIndex.get(reply.index);
@@ -65,7 +99,7 @@ const useFreshReplies = (replies: Comment[] = []) => {
}); });
return sortRepliesForDisplay(hasDuplicateReplyIndices ? dedupedReplies : nextReplies); return sortRepliesForDisplay(hasDuplicateReplyIndices ? dedupedReplies : nextReplies);
}, [accountComments, replies]); }, [accountCommentsByCidList, accountCommentsByIndexList, replies]);
}; };
export default useFreshReplies; export default useFreshReplies;
@@ -27,13 +27,14 @@ describe('getThreadPostCountsByAuthor', () => {
expect(counts.get('author-b')).toBe(2); expect(counts.get('author-b')).toBe(2);
}); });
it('skips comments missing a cid or short address', () => { it('uses cid fallback for comments without author metadata', () => {
const counts = getThreadPostCountsByAuthor( const counts = getThreadPostCountsByAuthor(
{ cid: 'post-1', author: { shortAddress: 'author-a' } } as any, { cid: 'post-1', author: { shortAddress: 'author-a' } } as any,
[{ cid: 'reply-1' }, { author: { shortAddress: 'author-b' } }] as any[], [{ cid: 'reply-1' }, { author: { shortAddress: 'author-b' } }] as any[],
); );
expect(counts.get('author-a')).toBe(1); expect(counts.get('author-a')).toBe(1);
expect(counts.get('reply-1')).toBe(1);
expect(counts.has('author-b')).toBe(false); expect(counts.has('author-b')).toBe(false);
}); });
}); });
+5 -4
View File
@@ -1,4 +1,5 @@
import { Comment } from '@bitsocial/bitsocial-react-hooks'; import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import { getCommentUserID } from './comment-user-id-utils';
export function getThreadPostCountsByAuthor(post: Comment | undefined, replies: Comment[] = []): Map<string, number> { export function getThreadPostCountsByAuthor(post: Comment | undefined, replies: Comment[] = []): Map<string, number> {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
@@ -6,11 +7,11 @@ export function getThreadPostCountsByAuthor(post: Comment | undefined, replies:
for (const comment of [post, ...replies]) { for (const comment of [post, ...replies]) {
const cid = comment?.cid; const cid = comment?.cid;
const shortAddress = comment?.author?.shortAddress; const userID = getCommentUserID(comment);
if (!cid || !shortAddress || seenCids.has(cid)) continue; if (!cid || !userID || seenCids.has(cid)) continue;
seenCids.add(cid); seenCids.add(cid);
counts.set(shortAddress, (counts.get(shortAddress) ?? 0) + 1); counts.set(userID, (counts.get(userID) ?? 0) + 1);
} }
return counts; return counts;
+7
View File
@@ -0,0 +1,7 @@
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../get-short-address';
export function getCommentUserID(comment: Comment | undefined): string {
const { address, shortAddress } = comment?.author || {};
return (address ? getShortAddress(address) : '') || shortAddress || comment?.cid || '';
}