From 6297edb49fdb7eba392a8165706b0ade47b84269 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 18 Jun 2026 15:59:14 +0700 Subject: [PATCH] fix(post): restore thread author controls after publish navigation Overlay local account comment author data on thread pages so edit/delete controls stay available after navigation from publish. --- .../5chan-directories-defaults.json | 8 +- src/views/post/__tests__/post.test.tsx | 155 ++++++++++++++++++ src/views/post/post.tsx | 115 ++++++++++--- 3 files changed, 252 insertions(+), 26 deletions(-) diff --git a/src/data/5chan-directories/5chan-directories-defaults.json b/src/data/5chan-directories/5chan-directories-defaults.json index 9ce98907..bf5a31cb 100644 --- a/src/data/5chan-directories/5chan-directories-defaults.json +++ b/src/data/5chan-directories/5chan-directories-defaults.json @@ -506,7 +506,7 @@ "postsPerPage": 15 }, "rules": [ - "This is the destination for all cartoon/anime pony related content on 4chan.", + "This is the destination for all cartoon/anime pony related content on 5chan.", "This is a work safe board. No pornographic images or other not safe for work content is allowed (this includes clop).", "Topics must be show-related. When discussing people, they must be associated with the show and not the fandom.", "No roleplay.", @@ -562,7 +562,7 @@ "safeForWork": true, "hasFlags": false, "requirePostLink": false, - "requirePostLinkIsMedia": false, + "requirePostLinkIsMedia": true, "bumpLimit": 300, "noSpoilers": false, "noSpoilerReplies": false, @@ -748,9 +748,7 @@ "postsPerPage": 15 }, "rules": [ - "You must check your #fortune in order to post on this board.", - "No porn dump threads. Keep that shit on /b/.", - "Moderators must check their #fortune as well." + "No porn dump threads. Keep that shit on /b/." ] }, "sci": { diff --git a/src/views/post/__tests__/post.test.tsx b/src/views/post/__tests__/post.test.tsx index 4badca9a..3c19a2d0 100644 --- a/src/views/post/__tests__/post.test.tsx +++ b/src/views/post/__tests__/post.test.tsx @@ -11,6 +11,11 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise type TestComment = { approved?: boolean; + accountId?: string; + author?: { + address?: string; + community?: unknown; + }; cid?: string; content?: string; error?: Error; @@ -35,6 +40,7 @@ type TestComment = { }; const testState = vi.hoisted(() => ({ + accountCommentsByCid: {} as Record, cachedComments: {} as Record, communityFieldAddress: undefined as string | undefined, commentsByCid: {} as Record, @@ -78,6 +84,8 @@ vi.mock('react-router-dom', async () => { }); vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + useAccount: () => ({ id: 'active-account', author: { address: 'account-author' } }), + useAccountComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.accountCommentsByCid[commentCid] : undefined), useComment: ({ commentCid, autoUpdate, community }: { commentCid?: string; autoUpdate?: boolean; community?: { name?: string; publicKey?: string } }) => { testState.useCommentCalls.push({ commentCid, autoUpdate, community }); return commentCid ? testState.commentsByCid[commentCid] : undefined; @@ -177,6 +185,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-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(',') || '', @@ -204,6 +213,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-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(',') || '', @@ -251,6 +261,7 @@ const renderPostPage = async (initialEntry: string | { pathname: string; state?: describe('Post', () => { beforeEach(() => { vi.clearAllMocks(); + testState.accountCommentsByCid = {}; testState.cachedComments = {}; testState.communityFieldAddress = undefined; testState.commentsByCid = {}; @@ -399,6 +410,41 @@ describe('Post', () => { expect(desktopPresenter?.getAttribute('data-approved')).toBe('true'); }); + it('rerenders posts when local author data becomes available after the remote post shell', async () => { + await act(async () => { + root.render( + createElement(Post, { + post: { + cid: 'post-author', + communityAddress: 'music-posting.eth', + content: 'body', + replyCount: 0, + }, + }), + ); + }); + + expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-author-address')).toBe(''); + + await act(async () => { + root.render( + createElement(Post, { + post: { + author: { + address: 'account-author', + }, + cid: 'post-author', + communityAddress: 'music-posting.eth', + content: 'body', + replyCount: 0, + }, + }), + ); + }); + + expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-author-address')).toBe('account-author'); + }); + it('hydrates thread pages from cached feed data, sets the document title, and renders thread footers', async () => { testState.commentsByCid = { 'cached-cid': { @@ -429,6 +475,115 @@ describe('Post', () => { expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); }); + it('overlays local account comments on thread pages so author controls receive account author data', async () => { + testState.commentsByCid = { + 'owned-cid': { + cid: 'owned-cid', + author: { + community: { displayName: 'Anonymous' }, + }, + content: 'remote body', + number: 14, + replyCount: 3, + communityAddress: 'music-posting.eth', + title: 'Remote thread', + }, + }; + testState.accountCommentsByCid = { + 'owned-cid': { + cid: 'owned-cid', + postCid: 'owned-cid', + author: { + address: 'account-author', + }, + content: 'local body', + communityAddress: 'music-posting.eth', + title: 'Local thread', + }, + }; + + await renderPostPage('/mu/thread/owned-cid'); + + const desktopPresenter = container.querySelector('[data-testid="post-desktop"]'); + expect(desktopPresenter?.getAttribute('data-author-address')).toBe('account-author'); + expect(desktopPresenter?.getAttribute('data-number')).toBe('14'); + expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('owned-cid:14:music-posting.eth:false'); + }); + + it('restores the active account author when a mapped account comment only has anonymous author metadata', async () => { + testState.commentsByCid = { + 'mapped-owned-cid': { + cid: 'mapped-owned-cid', + author: { + community: { displayName: 'Anonymous' }, + }, + content: 'remote body', + number: 16, + replyCount: 0, + communityAddress: 'music-posting.eth', + title: 'Remote thread', + }, + }; + testState.accountCommentsByCid = { + 'mapped-owned-cid': { + accountId: 'active-account', + cid: 'mapped-owned-cid', + postCid: 'mapped-owned-cid', + author: { + community: { displayName: 'Anonymous' }, + }, + content: 'local body', + communityAddress: 'music-posting.eth', + title: 'Local thread', + }, + }; + + await renderPostPage('/mu/thread/mapped-owned-cid'); + + const desktopPresenter = container.querySelector('[data-testid="post-desktop"]'); + expect(desktopPresenter?.getAttribute('data-author-address')).toBe('account-author'); + expect(desktopPresenter?.getAttribute('data-number')).toBe('16'); + }); + + it('keeps queued publish authors on thread pages when the remote OP is already renderable', async () => { + testState.commentsByCid = { + 'queued-owned-cid': { + cid: 'queued-owned-cid', + author: { + community: { displayName: 'Anonymous' }, + }, + content: 'remote body', + number: 15, + pendingApproval: false, + replyCount: 0, + communityAddress: 'music-posting.eth', + title: 'Remote thread', + }, + }; + + await renderPostPage({ + pathname: '/mu/thread/queued-owned-cid', + state: { + queuedComment: { + cid: 'queued-owned-cid', + author: { + address: 'account-author', + }, + content: 'queued body', + communityAddress: 'music-posting.eth', + pendingApproval: true, + title: 'Queued thread', + }, + }, + }); + + const desktopPresenter = container.querySelector('[data-testid="post-desktop"]'); + expect(desktopPresenter?.getAttribute('data-author-address')).toBe('account-author'); + expect(desktopPresenter?.getAttribute('data-number')).toBe('15'); + expect(desktopPresenter?.getAttribute('data-pending-approval')).toBe('false'); + expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('queued-owned-cid:15:music-posting.eth:false'); + }); + it('passes archived OP state through to thread footers as closed', async () => { testState.commentsByCid = { 'archived-thread': { diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 853b5e24..af6089ae 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -1,6 +1,16 @@ import { memo, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { type Comment, type CommunityIdentifier, type Role, useComment, useEditedComment, useCommunity, useReplies } from '@bitsocial/bitsocial-react-hooks'; +import { + type Account, + type Comment, + type CommunityIdentifier, + type Role, + useAccount, + useComment, + useEditedComment, + useCommunity, + useReplies, +} from '@bitsocial/bitsocial-react-hooks'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import { useCommunityField } from '../../hooks/use-stable-community'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; @@ -20,6 +30,7 @@ import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/ import { evictThreadRefreshCaches } from '../../lib/utils/thread-refresh-cache-utils'; import { REPLIES_PER_PAGE } from '../../lib/constants'; import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store'; +import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils'; import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates'; import styles from './post.module.css'; @@ -36,6 +47,19 @@ export type CommentWithRefresh = Comment & { removed?: boolean; }; +const mergeDefinedFields = (base: T | undefined, override: T | undefined): T | undefined => { + if (!override) return base; + + const merged = { ...base } as Record; + for (const [key, value] of Object.entries(override)) { + if (value !== undefined) { + merged[key] = value; + } + } + + return merged as T; +}; + const getRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => { if (!state || typeof state !== 'object') return undefined; if ('queuedComment' in state || 'scrollThreadContainerCid' in state) { @@ -62,25 +86,6 @@ interface ReplyPaginationOverride { reset?: () => Promise; } -// useComment may not return cached feed data immediately due to its updatedAt comparison logic. -// This hook falls back to the communities pages store (populated by useFeed) so content -// from the catalog appears instantly instead of going through a loading phase. -const useCommentWithFeedCache = (options: { commentCid: string | undefined; autoUpdate?: boolean; community?: CommunityIdentifier }): CommentWithRefresh | undefined => { - const comment = useComment(options); - const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']); - - return useMemo(() => { - if (!cachedComment || comment?.timestamp) return comment; - return { - ...cachedComment, - refresh: comment?.refresh, - state: comment?.state, - error: comment?.error, - errors: comment?.errors, - } as CommentWithRefresh; - }, [comment, cachedComment]); -}; - const getQueuedCommentFromRouteState = (state: unknown, commentCid: string | undefined): CommentWithRefresh | undefined => { if (!commentCid) return undefined; @@ -117,6 +122,71 @@ const mergeCommentFallback = (comment: CommentWithRefresh | undefined, fallback: }; }; +const mergeLocalCommentAuthor = (comment: CommentWithRefresh | undefined, localComment: CommentWithRefresh | undefined): CommentWithRefresh | undefined => { + if (!localComment?.author) return comment; + if (!comment) return localComment; + if (comment.cid && localComment.cid && comment.cid !== localComment.cid) return comment; + + const mergedAuthor = mergeDefinedFields(comment.author, localComment.author); + + return { + ...comment, + ...(mergedAuthor ? { author: mergedAuthor } : {}), + }; +}; + +const mergeLocalAccountComment = (comment: CommentWithRefresh | undefined, accountComment: CommentWithRefresh | undefined): CommentWithRefresh | undefined => { + if (!accountComment) return comment; + if (!comment) return accountComment; + if (comment.cid && accountComment.cid && comment.cid !== accountComment.cid) return comment; + + const mergedComment = mergeDefinedFields(comment, accountComment) ?? comment; + return mergeLocalCommentAuthor(mergedComment, accountComment); +}; + +const restoreActiveAccountAuthor = (accountComment: CommentWithRefresh | undefined, account: Account | undefined): CommentWithRefresh | undefined => { + if (!accountComment || accountComment.author?.address || !account?.id || accountComment.accountId !== account.id || !account.author?.address) { + return accountComment; + } + + const accountAuthor = { + address: account.author.address, + shortAddress: account.author.shortAddress, + displayName: account.author.displayName, + avatar: account.author.avatar, + flair: account.author.flair, + }; + + return { + ...accountComment, + author: mergeDefinedFields(accountComment.author, accountAuthor), + }; +}; + +// useComment may not return cached feed data immediately due to its updatedAt comparison logic. +// This hook falls back to the communities pages store and then overlays a matching +// local account comment so author controls keep working after publish navigation. +const useCommentWithFeedCache = (options: { commentCid: string | undefined; autoUpdate?: boolean; community?: CommunityIdentifier }): CommentWithRefresh | undefined => { + const comment = useComment(options); + const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']); + const account = useAccount(); + const accountComment = useSafeAccountComment({ commentCid: options.commentCid }) as CommentWithRefresh | undefined; + const accountCommentWithAuthor = useMemo(() => restoreActiveAccountAuthor(accountComment, account), [accountComment, account]); + + const commentWithFeedCache = useMemo(() => { + if (!cachedComment || comment?.timestamp) return comment; + return { + ...cachedComment, + refresh: comment?.refresh, + state: comment?.state, + error: comment?.error, + errors: comment?.errors, + } as CommentWithRefresh; + }, [comment, cachedComment]); + + return useMemo(() => mergeLocalAccountComment(commentWithFeedCache, accountCommentWithAuthor), [commentWithFeedCache, accountCommentWithAuthor]); +}; + const mergeRepliesWithQueuedReply = (replies: Comment[], queuedReply: CommentWithRefresh | undefined): Comment[] => { if (!queuedReply?.cid) { return replies; @@ -243,6 +313,9 @@ export const Post = memo( prev?.updatedAt === next?.updatedAt && prev?.state === next?.state && prev?.publishingState === next?.publishingState && + prev?.author?.address === next?.author?.address && + prev?.author?.displayName === next?.author?.displayName && + prev?.author?.shortAddress === next?.author?.shortAddress && prev?.error === next?.error && prev?.errors === next?.errors && prev?.approved === next?.approved && @@ -294,7 +367,7 @@ const PostPage = () => { const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled, community: resolvedCommunityIdentifier }); const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]); - const comment = useMemo(() => mergeCommentFallback(resolvedComment, queuedComment), [resolvedComment, queuedComment]); + const comment = useMemo(() => mergeLocalCommentAuthor(mergeCommentFallback(resolvedComment, queuedComment), queuedComment), [resolvedComment, queuedComment]); const commentCommunityAddress = getCommentCommunityAddress(comment); const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress; const communityIdentifier = useCommunityIdentifier(communityAddress);