mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 : ''}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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='' />}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 || '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user