From abe1e5a6a2bf7a746675caea257a2a33898958ea Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Tue, 7 Jul 2026 16:27:41 +0700 Subject: [PATCH] fix(post ids): never display account identity as a post user id (#1184) * fix(post ids): never display account identity as a post user id In per-post pseudonymity mode, local account comment data is overlaid on published comments so author edit/delete controls keep working, but the overlay also replaced the displayed User ID with the account author address in logged-in thread views. Split the two concerns: getCommentUserID now reads only the published author.shortAddress, and preservePublishedUserID mirrors the published ID back onto comments after any local account/edit overlay. * test(post): cover published user id restore over edited comment overlay --- .../post-community-address-compat.test.tsx | 41 ++++++++++++--- src/components/post-desktop/post-desktop.tsx | 6 +-- src/components/post-mobile/post-mobile.tsx | 6 +-- .../__tests__/comment-user-id-utils.test.ts | 52 +++++++++++++++++-- src/lib/utils/comment-user-id-utils.ts | 29 +++++++++-- src/views/post/__tests__/post.test.tsx | 31 +++++++++++ src/views/post/post.tsx | 22 +++++--- 7 files changed, 163 insertions(+), 24 deletions(-) diff --git a/src/components/__tests__/post-community-address-compat.test.tsx b/src/components/__tests__/post-community-address-compat.test.tsx index 305dc78d..e11678a3 100644 --- a/src/components/__tests__/post-community-address-compat.test.tsx +++ b/src/components/__tests__/post-community-address-compat.test.tsx @@ -15,6 +15,7 @@ type TestComment = { displayName?: string; shortAddress?: string; }; + accountId?: string; cid?: string; communityAddress?: string; content?: string; @@ -583,21 +584,46 @@ describe('post community address compatibility', () => { expect(container.querySelector('img[title="Anarcho-Capitalist"]')).toBeTruthy(); }); - it('falls back to author shortAddress when the full author address cannot be shortened', async () => { + it('renders the author shortAddress instead of deriving a user ID from the author address', async () => { testState.pseudonymityMode = 'per-post'; const post = { ...makeLegacyThreadWithoutReplies(), - author: { address: 'short-address', shortAddress: 'B2mAZojE' }, + author: { address: 'account-author.bso', shortAddress: 'B2mAZojE' }, }; await renderWithRoute(createElement(PostDesktop, { post } as any), '/mu/thread/post-1'); expect(container.textContent).toContain('ID: B2mAZojE'); + expect(container.textContent).not.toContain('account-author.bso'); await renderWithRoute(createElement(PostMobile, { post } as any), '/mu/thread/post-1'); expect(container.textContent).toContain('ID: B2mAZojE'); + expect(container.textContent).not.toContain('account-author.bso'); }); - it('uses safe account reply data by cid when a reply has no index', async () => { + it('keeps the published reply shortAddress when local account reply data has account author identity', 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.author = { address: 'published-reply-address', shortAddress: 'ReplyKid9' }; + testState.accountCommentsByCid[reply.cid] = { + ...reply, + accountId: 'viewer-account', + author: { address: 'account-author.bso', shortAddress: 'account-author' }, + }; + + await renderWithRoute(createElement(PostDesktop, { post, showAllReplies: true }), '/mu/thread/post-1'); + expect(container.textContent).toContain('ID: ReplyKid'); + expect(container.textContent).not.toContain('ID: account'); + + await renderWithRoute(createElement(PostMobile, { post, showAllReplies: true }), '/mu/thread/post-1'); + expect(container.textContent).toContain('ID: ReplyKid'); + expect(container.textContent).not.toContain('ID: account'); + }); + + it('hides account author identity when a cid-matched reply has no published user ID yet', async () => { testState.pseudonymityMode = 'per-post'; const post = makeLegacyThread(); const reply = post.replies?.pages?.new?.comments?.[0]; @@ -608,14 +634,17 @@ describe('post community address compatibility', () => { reply.author = {}; testState.accountCommentsByCid[reply.cid] = { ...reply, - author: { shortAddress: 'ReplyKid9' }, + accountId: 'viewer-account', + author: { address: 'account-author.bso', shortAddress: 'account-author' }, }; await renderWithRoute(createElement(PostDesktop, { post, showAllReplies: true }), '/mu/thread/post-1'); - expect(container.textContent).toContain('ID: ReplyKid'); + expect(container.textContent).toContain('ID: Pending'); + expect(container.textContent).not.toContain('ID: account'); await renderWithRoute(createElement(PostMobile, { post, showAllReplies: true }), '/mu/thread/post-1'); - expect(container.textContent).toContain('ID: ReplyKid'); + expect(container.textContent).toContain('ID: Pending'); + expect(container.textContent).not.toContain('ID: account'); }); it('shows a pending ID while published reply author metadata is missing', async () => { diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 748b88bd..10407d4d 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -67,7 +67,7 @@ import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../l import useDeleteFailedPost from '../../hooks/use-delete-failed-post'; import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts'; import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; -import { getCommentUserID } from '../../lib/utils/comment-user-id-utils'; +import { getCommentUserID, preservePublishedUserID } from '../../lib/utils/comment-user-id-utils'; import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates'; import { getAuthorBadge } from '../../lib/utils/author-display-utils'; import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection'; @@ -706,11 +706,11 @@ const Reply = ({ }); const hasReplyIndex = typeof reply?.index === 'number'; const isAccountReply = (hasReplyIndex && accountReply?.index === reply.index) || (!!reply?.cid && accountReply?.cid === reply.cid); - let post = isAccountReply ? accountReply : reply; + let post = isAccountReply ? preservePublishedUserID(accountReply, reply) : reply; // handle pending mod or author edit const { editedComment } = useEditedComment({ comment: post }); if (editedComment) { - post = editedComment; + post = preservePublishedUserID(editedComment, reply); } post = withResolvedCommentCommunityAddress(post); diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 5c8b623e..5cc72d5e 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -58,7 +58,7 @@ import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../l import useDeleteFailedPost from '../../hooks/use-delete-failed-post'; import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts'; import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils'; -import { getCommentUserID } from '../../lib/utils/comment-user-id-utils'; +import { getCommentUserID, preservePublishedUserID } from '../../lib/utils/comment-user-id-utils'; import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates'; import { getAuthorBadge } from '../../lib/utils/author-display-utils'; import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection'; @@ -472,11 +472,11 @@ const Reply = ({ }); const hasReplyIndex = typeof reply?.index === 'number'; const isAccountReply = (hasReplyIndex && accountReply?.index === reply.index) || (!!reply?.cid && accountReply?.cid === reply.cid); - let post = isAccountReply ? accountReply : reply; + let post = isAccountReply ? preservePublishedUserID(accountReply, reply) : reply; // handle pending mod or author edit const { editedComment } = useEditedComment({ comment: post }); if (editedComment) { - post = editedComment; + post = preservePublishedUserID(editedComment, reply); } post = withResolvedCommentCommunityAddress(post); const { cid, deleted, postCid, reason, removed, communityAddress } = post || {}; diff --git a/src/lib/utils/__tests__/comment-user-id-utils.test.ts b/src/lib/utils/__tests__/comment-user-id-utils.test.ts index 8d4a090a..51c78292 100644 --- a/src/lib/utils/__tests__/comment-user-id-utils.test.ts +++ b/src/lib/utils/__tests__/comment-user-id-utils.test.ts @@ -1,13 +1,59 @@ import { describe, expect, it } from 'vitest'; -import { getCommentUserID } from '../comment-user-id-utils'; +import { getCommentUserID, preservePublishedUserID } from '../comment-user-id-utils'; describe('getCommentUserID', () => { - it('uses resolved author identity data for per-post user IDs', () => { - expect(getCommentUserID({ author: { address: 'poster.bso', shortAddress: 'short-poster' } } as any)).toBe('poster.bso'); + it('uses only the comment author short address for per-post user IDs', () => { + expect(getCommentUserID({ author: { address: 'poster.bso', shortAddress: 'short-poster' } } as any)).toBe('short-poster'); expect(getCommentUserID({ author: { shortAddress: 'short-poster' } } as any)).toBe('short-poster'); }); + it('does not derive a user ID from an author address', () => { + expect(getCommentUserID({ author: { address: 'poster.bso' } } as any)).toBe(''); + }); + it('does not fall back to the comment cid while author metadata is missing', () => { expect(getCommentUserID({ cid: 'QmQU6BiPkB77b3rAkdj973ptTp5A6X77tfPrwTvCQDL9cq' } as any)).toBe(''); }); }); + +describe('preservePublishedUserID', () => { + it('keeps local account author data but preserves the published user ID', () => { + const merged = preservePublishedUserID( + { + accountId: 'viewer-account', + author: { address: 'account-author.bso', shortAddress: 'account-author' }, + } as any, + { author: { address: 'published-reply-address', shortAddress: 'ReplyKid9' } } as any, + ); + + expect(merged.author.address).toBe('account-author.bso'); + expect(getCommentUserID(merged)).toBe('ReplyKid9'); + }); + + it('removes an overlaid shortAddress when the published author has no user ID', () => { + const merged = preservePublishedUserID( + { + accountId: 'viewer-account', + author: { address: 'account-author.bso', shortAddress: 'account-author' }, + } as any, + { author: {} } as any, + ); + + expect(merged.author.address).toBe('account-author.bso'); + expect(getCommentUserID(merged)).toBe(''); + }); + + it('mirrors the published user ID even when the overlaid comment is not an account comment', () => { + const merged = preservePublishedUserID( + { author: { address: 'account-author.bso', shortAddress: 'account-author' } } as any, + { author: { shortAddress: 'ReplyKid9' } } as any, + ); + + expect(getCommentUserID(merged)).toBe('ReplyKid9'); + }); + + it('returns the comment unchanged when the user ID already matches', () => { + const comment = { author: { address: 'account-author.bso', shortAddress: 'ReplyKid9' } } as any; + expect(preservePublishedUserID(comment, { author: { shortAddress: 'ReplyKid9' } } as any)).toBe(comment); + }); +}); diff --git a/src/lib/utils/comment-user-id-utils.ts b/src/lib/utils/comment-user-id-utils.ts index 4bac5f8b..be4ec3df 100644 --- a/src/lib/utils/comment-user-id-utils.ts +++ b/src/lib/utils/comment-user-id-utils.ts @@ -1,7 +1,30 @@ import type { Comment } from '@bitsocial/bitsocial-react-hooks'; -import getShortAddress from '../get-short-address'; +// The published comment's author.shortAddress is the pseudonymous per-post User ID. +// author.address can carry the local account identity (overlaid so author edit/delete +// controls keep working), so it must never be used for display. export function getCommentUserID(comment: Comment | undefined): string { - const { address, shortAddress } = comment?.author || {}; - return (address ? getShortAddress(address) : '') || shortAddress || ''; + return comment?.author?.shortAddress || ''; +} + +// After local account data (account comment, pending edit) is overlaid on a published +// comment, mirror the published comment's User ID back onto the result so the account +// identity is never displayed. +export function preservePublishedUserID(comment: T, published: Comment | undefined): T { + if (!comment || !published || comment === published) return comment; + + const publishedShortAddress = published.author?.shortAddress; + if (comment.author?.shortAddress === publishedShortAddress) return comment; + + const author = { ...comment.author }; + if (publishedShortAddress) { + author.shortAddress = publishedShortAddress; + } else { + delete author.shortAddress; + } + + return { + ...comment, + author, + } as T; } diff --git a/src/views/post/__tests__/post.test.tsx b/src/views/post/__tests__/post.test.tsx index aedb671b..fbe339a5 100644 --- a/src/views/post/__tests__/post.test.tsx +++ b/src/views/post/__tests__/post.test.tsx @@ -14,6 +14,7 @@ type TestComment = { accountId?: string; author?: { address?: string; + shortAddress?: string; community?: unknown; }; cid?: string; @@ -214,6 +215,7 @@ vi.mock('../../../components/post-desktop/post-desktop', () => ({ 'data-testid': 'post-desktop', 'data-approved': post?.approved === undefined ? '' : String(post.approved), 'data-author-address': post?.author?.address || '', + 'data-author-short-address': post?.author?.shortAddress || '', 'data-number': post?.number === undefined ? '' : String(post.number), 'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval), 'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '', @@ -247,6 +249,7 @@ vi.mock('../../../components/post-mobile/post-mobile', () => ({ 'data-testid': 'post-mobile', 'data-approved': post?.approved === undefined ? '' : String(post.approved), 'data-author-address': post?.author?.address || '', + 'data-author-short-address': post?.author?.shortAddress || '', 'data-number': post?.number === undefined ? '' : String(post.number), 'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval), 'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '', @@ -390,6 +393,34 @@ describe('Post', () => { expect(container.querySelector('[data-testid="post-mobile"]')?.textContent).toBe('post-2:none:1'); }); + it('restores the published user ID when an edited comment overlays local author identity', async () => { + testState.editedCommentsByCid = { + 'post-edited-id': { + cid: 'post-edited-id', + communityAddress: 'music-posting.eth', + content: 'edited body', + author: { address: 'account-author.bso', shortAddress: 'account-author' }, + }, + }; + + await act(async () => { + root.render( + createElement(Post, { + post: { + cid: 'post-edited-id', + communityAddress: 'music-posting.eth', + content: 'body', + author: { address: 'published-address', shortAddress: 'PostKid9' }, + }, + }), + ); + }); + + const presenter = container.querySelector('[data-testid="post-desktop"]'); + expect(presenter?.getAttribute('data-author-address')).toBe('account-author.bso'); + expect(presenter?.getAttribute('data-author-short-address')).toBe('PostKid9'); + }); + it('keeps renderable post data when edited comments only resolve to a loading shell', async () => { testState.editedCommentsByCid = { 'post-shell': { diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 882fad16..8e05ced4 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -29,6 +29,7 @@ import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/ import { evictThreadRefreshCaches } from '../../lib/utils/thread-refresh-cache-utils'; import { hasTransferredCommentMarker } from '../../lib/comment-transfer'; import { REPLIES_PER_PAGE } from '../../lib/constants'; +import { preservePublishedUserID } from '../../lib/utils/comment-user-id-utils'; import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store'; import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils'; import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates'; @@ -130,10 +131,13 @@ const mergeLocalCommentAuthor = (comment: CommentWithRefresh | undefined, localC const mergedAuthor = mergeDefinedFields(comment.author, localComment.author); - return { - ...comment, - ...(mergedAuthor ? { author: mergedAuthor } : {}), - }; + return preservePublishedUserID( + { + ...comment, + ...(mergedAuthor ? { author: mergedAuthor } : {}), + }, + comment, + ); }; const mergeLocalAccountComment = (comment: CommentWithRefresh | undefined, accountComment: CommentWithRefresh | undefined): CommentWithRefresh | undefined => { @@ -142,7 +146,13 @@ const mergeLocalAccountComment = (comment: CommentWithRefresh | undefined, accou if (comment.cid && accountComment.cid && comment.cid !== accountComment.cid) return comment; const mergedComment = mergeDefinedFields(comment, accountComment) ?? comment; - return mergeLocalCommentAuthor(mergedComment, accountComment); + // mergeDefinedFields replaces author wholesale, so re-merge author field-wise from the + // published comment (mergeLocalCommentAuthor also preserves the published User ID) + const commentWithMergedAuthor = mergeLocalCommentAuthor(comment, accountComment); + return { + ...mergedComment, + ...(commentWithMergedAuthor?.author ? { author: commentWithMergedAuthor.author } : {}), + }; }; // useComment may not return cached feed data immediately due to its updatedAt comparison logic. @@ -241,7 +251,7 @@ export const Post = memo( // handle pending mod or author edit const { editedComment } = useEditedComment({ comment }); - comment = mergeCommentFallback(editedComment as CommentWithRefresh | undefined, comment as CommentWithRefresh | undefined); + comment = preservePublishedUserID(mergeCommentFallback(editedComment as CommentWithRefresh | undefined, comment as CommentWithRefresh | undefined), post); const transferHandler = comment?.parentCid ? undefined : onTransfer; return (