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
This commit is contained in:
Tommaso Casaburi
2026-07-07 16:27:41 +07:00
committed by GitHub
parent 6c9c57794c
commit abe1e5a6a2
7 changed files with 163 additions and 24 deletions
@@ -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 () => {
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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 || {};
@@ -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);
});
});
+26 -3
View File
@@ -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<T extends Comment | undefined>(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;
}
+31
View File
@@ -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': {
+16 -6
View File
@@ -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 (