diff --git a/src/components/post-transfer-modal/__tests__/post-transfer-modal.test.tsx b/src/components/post-transfer-modal/__tests__/post-transfer-modal.test.tsx index 7d1ab14d..d2410c8e 100644 --- a/src/components/post-transfer-modal/__tests__/post-transfer-modal.test.tsx +++ b/src/components/post-transfer-modal/__tests__/post-transfer-modal.test.tsx @@ -161,6 +161,58 @@ describe('PostTransferModal', () => { expect(document.body.querySelector('select')).toBeNull(); }); + it('publishes hidden trash board transfers with the board public key identity', async () => { + await renderTransferModal(); + + expect(document.body.querySelector('select')).toBeNull(); + + const form = document.body.querySelector('form'); + expect(form).not.toBeNull(); + await act(async () => { + form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + + const [publishOptions] = testState.publishCommentMock.mock.calls[0] as [Record, string]; + expect(publishOptions).toMatchObject({ + communityAddress: TRASH_BOARD_PUBLIC_KEY, + communityPublicKey: TRASH_BOARD_PUBLIC_KEY, + }); + expect(publishOptions).not.toHaveProperty('communityName'); + }); + + it('unlocks after hidden trash board challenge verification fails', async () => { + await renderTransferModal(); + + const form = document.body.querySelector('form'); + expect(form).not.toBeNull(); + await act(async () => { + form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + + const [publishOptions] = testState.publishCommentMock.mock.calls[0] as [Record, string]; + (publishOptions._onPendingCommentIndex as (index: number) => void)(12); + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); + await act(async () => { + await (publishOptions.onChallengeVerification as (verification: unknown, comment: unknown) => Promise)( + { challengeSuccess: false, reason: 'try again' }, + { cid: 'challenge-comment' }, + ); + await Promise.resolve(); + }); + + expect(alertSpy).toHaveBeenCalledTimes(1); + expect(testState.deleteCommentMock).toHaveBeenCalledWith(12, expect.stringMatching(/^5chan-transfer-comment-/)); + expect(testState.deleteAccountMock).toHaveBeenCalledWith(expect.stringMatching(/^5chan-transfer-comment-/)); + expect(document.body.textContent).toContain('try again'); + const closeButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent === 'close'); + const submitButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.type === 'submit'); + expect(closeButton?.disabled).toBe(false); + expect(submitButton?.disabled).toBe(false); + alertSpy.mockRestore(); + }); + it('disables submission when the hidden trash board is the transfer source', async () => { await renderTransferModal({ ...baseComment, communityAddress: TRASH_BOARD_ADDRESS }); diff --git a/src/components/post-transfer-modal/post-transfer-modal.tsx b/src/components/post-transfer-modal/post-transfer-modal.tsx index 33a68637..fe15051f 100644 --- a/src/components/post-transfer-modal/post-transfer-modal.tsx +++ b/src/components/post-transfer-modal/post-transfer-modal.tsx @@ -19,6 +19,7 @@ import { getInitialTransferFields, getTargetTransferModerationFlairs, getTransferBoardReference, + getTransferPublishIdentity, getTransferPublishPayload, getTransferSourceBoardReference, getTransferSourceBoardRulesLink, @@ -333,7 +334,9 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer try { await createAccount(temporaryAccountName); temporaryAccountCreated = true; - const payload = getTransferPublishPayload(comment, selectedFields, resolvedTargetBoardAddress); + const targetPublishIdentity = getTransferPublishIdentity(resolvedTargetBoard, resolvedTargetBoardAddress); + const sourcePublishIdentity = sourceCommunityAddress ? getTransferPublishIdentity(sourceBoard, sourceCommunityAddress) : undefined; + const payload = getTransferPublishPayload(comment, selectedFields, resolvedTargetBoardAddress, resolvedTargetBoard); await publishComment( { ...payload, @@ -351,6 +354,9 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer try { alertChallengeVerificationFailed(challengeVerification, challengeComment); if (challengeVerification?.challengeSuccess !== true) { + await cleanupTemporaryAccount({ deletePendingComment: true }); + onTransferStateChange?.('failed'); + dispatchModalState({ type: 'publishFailed', error: new Error(challengeVerification?.reason || 'Transfer challenge verification failed.') }); return; } if (finalizedTransferRef.current) return; @@ -360,11 +366,14 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer if (!targetCommentCid) { throw new Error('Transferred post was accepted, but no target CID was returned.'); } + if (!sourcePublishIdentity) { + throw new Error('Transferred post was accepted, but no source board was resolved.'); + } // Queue the target marker first so a target moderation failure does not remove the original post. await publishCommentModeration({ commentCid: targetCommentCid, - communityAddress: resolvedTargetBoardAddress, + ...targetPublishIdentity, commentModeration: { flairs: getTargetTransferModerationFlairs(comment, selectedFields), }, @@ -372,7 +381,7 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer }); await publishCommentModeration({ commentCid: sourceCommentCid, - communityAddress: sourceCommunityAddress, + ...sourcePublishIdentity, commentModeration: getTransferSourceModeration( comment, getTransferBoardReference(resolvedTargetBoard, resolvedTargetBoardAddress), diff --git a/src/lib/__tests__/comment-transfer.test.ts b/src/lib/__tests__/comment-transfer.test.ts index 2b18fcd1..dbead5a7 100644 --- a/src/lib/__tests__/comment-transfer.test.ts +++ b/src/lib/__tests__/comment-transfer.test.ts @@ -3,6 +3,7 @@ import { canMoveCommentToTrash, canTransferComment, getTargetTransferModerationFlairs, + getTransferPublishIdentity, getTransferPublishPayload, getTransferSourceBoardReference, getTransferSourceBoardRulesLink, @@ -66,6 +67,36 @@ describe('comment-transfer', () => { }); }); + it('uses the public key identity when publishing transfers to a special board', () => { + expect(getTransferPublishIdentity({ address: 'tech-posting.eth', directoryCode: 'g' }, 'tech-posting.eth')).toEqual({ + communityAddress: 'tech-posting.eth', + }); + expect(getTransferPublishIdentity({ address: TRASH_BOARD_ADDRESS, directoryCode: TRASH_BOARD_CODE, publicKey: TRASH_BOARD_PUBLIC_KEY }, TRASH_BOARD_ADDRESS)).toEqual( + { + communityAddress: TRASH_BOARD_PUBLIC_KEY, + communityPublicKey: TRASH_BOARD_PUBLIC_KEY, + }, + ); + + const payload = getTransferPublishPayload( + { + cid: 'source', + communityAddress: 'music-posting.eth', + content: 'wrong board', + } as never, + selectedFields, + TRASH_BOARD_ADDRESS, + { address: TRASH_BOARD_ADDRESS, directoryCode: TRASH_BOARD_CODE, publicKey: TRASH_BOARD_PUBLIC_KEY }, + ); + + expect(payload).toMatchObject({ + communityAddress: TRASH_BOARD_PUBLIC_KEY, + communityPublicKey: TRASH_BOARD_PUBLIC_KEY, + content: 'wrong board', + }); + expect(payload).not.toHaveProperty('communityName'); + }); + it('copies selected comment body text verbatim while still ignoring blank bodies', () => { expect( getTransferPublishPayload( diff --git a/src/lib/comment-transfer.ts b/src/lib/comment-transfer.ts index 6e3f750a..57cf58df 100644 --- a/src/lib/comment-transfer.ts +++ b/src/lib/comment-transfer.ts @@ -11,9 +11,16 @@ export type PostTransferFields = Record; export type TransferBoardLike = { address?: string; directoryCode?: string; + publicKey?: string; title?: string; }; +export type TransferPublishIdentity = { + communityAddress: string; + communityPublicKey?: string; + communityName?: string; +}; + export const TRANSFERRED_COMMENT_FLAIR_TEXT = '5chan:transferred'; export const TRANSFERRED_COMMENT_FLAIR = { text: TRANSFERRED_COMMENT_FLAIR_TEXT } as const; export const TRANSFER_FIELD_KEYS: PostTransferField[] = ['displayName', 'title', 'content', 'link', 'spoiler', 'flairs']; @@ -67,9 +74,30 @@ export const getAvailableTransferFields = (comment: Comment): PostTransferField[ export const hasSelectedTransferFields = (fields: PostTransferFields, availableFields: PostTransferField[]) => availableFields.some((field) => fields[field]); -export const getTransferPublishPayload = (comment: Comment, fields: PostTransferFields, targetBoardAddress: string): Record => { +export const getTransferPublishIdentity = (targetBoard: TransferBoardLike | undefined, targetBoardAddress: string): TransferPublishIdentity => { + const boardAddress = getTextField(targetBoard?.address) ?? targetBoardAddress; + const publicKey = getTextField(targetBoard?.publicKey); + + if (publicKey && isSpecialBoardCode(targetBoard?.directoryCode)) { + return { + communityAddress: publicKey, + communityPublicKey: publicKey, + }; + } + + return { + communityAddress: boardAddress, + }; +}; + +export const getTransferPublishPayload = ( + comment: Comment, + fields: PostTransferFields, + targetBoardAddress: string, + targetBoard?: TransferBoardLike, +): Record => { const payload: Record = { - communityAddress: targetBoardAddress, + ...getTransferPublishIdentity(targetBoard, targetBoardAddress), }; const displayName = getCommentDisplayName(comment); const title = getTextField(comment.title); diff --git a/src/lib/special-boards.ts b/src/lib/special-boards.ts index c85cfab7..2e48d7b0 100644 --- a/src/lib/special-boards.ts +++ b/src/lib/special-boards.ts @@ -3,6 +3,7 @@ export interface SpecialBoard { aliases?: string[]; directoryCode: string; nsfw?: boolean; + publicKey?: string; title: string; } @@ -14,14 +15,15 @@ export const TRASH_BOARD_TITLE = '/trash/ - Off-topic'; export const SPECIAL_BOARDS: SpecialBoard[] = [ { address: TRASH_BOARD_ADDRESS, - aliases: [TRASH_BOARD_PUBLIC_KEY, 'off-topic.eth'], + aliases: ['off-topic.eth'], directoryCode: TRASH_BOARD_CODE, nsfw: true, + publicKey: TRASH_BOARD_PUBLIC_KEY, title: TRASH_BOARD_TITLE, }, ]; -const getSpecialBoardLookupAddresses = (board: SpecialBoard): string[] => [board.address, ...(board.aliases ?? [])]; +const getSpecialBoardLookupAddresses = (board: SpecialBoard): string[] => [board.address, ...(board.publicKey ? [board.publicKey] : []), ...(board.aliases ?? [])]; export const getSpecialBoardByCode = (code: string | undefined): SpecialBoard | undefined => code ? SPECIAL_BOARDS.find((board) => board.directoryCode === code) : undefined; diff --git a/src/views/mod-queue/__tests__/mod-queue.test.tsx b/src/views/mod-queue/__tests__/mod-queue.test.tsx index 6b8d15cf..ed9c0302 100644 --- a/src/views/mod-queue/__tests__/mod-queue.test.tsx +++ b/src/views/mod-queue/__tests__/mod-queue.test.tsx @@ -4,7 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { Link, MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ModQueueView from '../mod-queue'; -import { TRASH_BOARD_ADDRESS } from '../../../lib/special-boards'; +import { TRASH_BOARD_ADDRESS, TRASH_BOARD_PUBLIC_KEY } from '../../../lib/special-boards'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const act = (React as { act?: (callback: () => void | Promise) => void | Promise }).act as (callback: () => void | Promise) => void | Promise; @@ -640,7 +640,8 @@ describe('ModQueueView', () => { const [payload, accountName] = testState.publishCommentMock.mock.calls[0]; expect(accountName).toBe(temporaryAccountName); expect(payload).toMatchObject({ - communityAddress: TRASH_BOARD_ADDRESS, + communityAddress: TRASH_BOARD_PUBLIC_KEY, + communityPublicKey: TRASH_BOARD_PUBLIC_KEY, content: 'belongs on tech', flairs: [{ text: 'flash:loop' }], link: 'https://example.com/image.png', @@ -648,20 +649,9 @@ describe('ModQueueView', () => { title: 'Wrong board', }); expect(payload.author).toBeUndefined(); + expect(payload).not.toHaveProperty('communityName'); expect(typeof payload.onChallengeVerification).toBe('function'); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); - await act(async () => { - await payload.onChallengeVerification({ challengeSuccess: false, reason: 'try again' }, { cid: 'retry-post', communityAddress: TRASH_BOARD_ADDRESS }); - await Promise.resolve(); - }); - - expect(alertSpy).toHaveBeenCalledTimes(1); - expect(testState.publishCommentModerationActionMock).not.toHaveBeenCalled(); - expect(testState.deleteAccountMock).not.toHaveBeenCalled(); - expect(dialog?.textContent).toContain('publishing'); - alertSpy.mockRestore(); - await act(async () => { await payload.onChallengeVerification({ challengeSuccess: true, commentUpdate: { cid: 'transferred-post' } }, { cid: 'transferred-post' }); await Promise.resolve(); @@ -670,7 +660,8 @@ describe('ModQueueView', () => { expect(testState.publishCommentModerationActionMock).toHaveBeenCalledTimes(2); expect(testState.publishCommentModerationActionMock.mock.calls[0][0]).toMatchObject({ commentCid: 'transferred-post', - communityAddress: TRASH_BOARD_ADDRESS, + communityAddress: TRASH_BOARD_PUBLIC_KEY, + communityPublicKey: TRASH_BOARD_PUBLIC_KEY, commentModeration: { flairs: [{ text: 'flash:loop' }, { text: '5chan:transferred' }], }, @@ -709,6 +700,72 @@ describe('ModQueueView', () => { expect(testState.publishCommentModerationActionMock).toHaveBeenCalledTimes(2); }); + it('unlocks the transfer modal when trash challenge verification fails', async () => { + testState.feed = [ + { + cid: 'wrong-board-post', + communityAddress: 'music-posting.eth', + content: 'belongs on trash', + number: 8, + pendingApproval: true, + timestamp: 90_000, + }, + ]; + let resolveDeleteAccount: () => void = () => undefined; + testState.deleteAccountMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveDeleteAccount = resolve; + }), + ); + + await renderModQueue(); + + const transferButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Trash'); + await act(async () => { + transferButton?.click(); + }); + + const dialog = document.body.querySelector('[role="dialog"][aria-labelledby="post-transfer-title"]'); + const submitButton = Array.from(dialog?.querySelectorAll('button') ?? []).find((button) => button.type === 'submit'); + const closeButton = Array.from(dialog?.querySelectorAll('button') ?? []).find((button) => button.textContent === 'close'); + + await act(async () => { + submitButton?.click(); + await Promise.resolve(); + }); + + const temporaryAccountName = testState.createAccountMock.mock.calls[0][0]; + const [payload] = testState.publishCommentMock.mock.calls[0]; + payload._onPendingCommentIndex(12); + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); + let verificationPromise: Promise | undefined; + await act(async () => { + verificationPromise = payload.onChallengeVerification({ challengeSuccess: false, reason: 'try again' }, { cid: 'failed-trash-post' }); + await Promise.resolve(); + }); + + expect(alertSpy).toHaveBeenCalledTimes(1); + expect(testState.deleteCommentMock).toHaveBeenCalledWith(12, temporaryAccountName); + expect(testState.deleteAccountMock).toHaveBeenCalledWith(temporaryAccountName); + expect(container.textContent).toContain('publishing'); + expect(closeButton?.disabled).toBe(true); + expect(submitButton?.disabled).toBe(true); + + await act(async () => { + resolveDeleteAccount(); + await verificationPromise; + await Promise.resolve(); + }); + + expect(dialog?.querySelector('[data-testid="error-display"]')?.textContent).toBe('try again'); + expect(container.textContent).not.toContain('publishing'); + expect(closeButton?.disabled).toBe(false); + expect(submitButton?.disabled).toBe(false); + expect(testState.publishCommentModerationActionMock).not.toHaveBeenCalled(); + alertSpy.mockRestore(); + }); + it('keeps post-acceptance transfer finalization failures from being retried', async () => { testState.directories = [ { address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },