import * as React from 'react'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ReplyModal from '../reply-modal'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; const testState = vi.hoisted(() => ({ account: { author: { displayName: 'Alice' } } as { author?: { displayName?: string } }, closeModalMock: vi.fn(), directoryByAddress: { 'music-posting.eth': { address: 'music-posting.eth', features: {}, }, } as Record }>, handleUploadMock: vi.fn(), isMobile: false, isUploading: false, offlineTitle: '' as string | false, offlineStatusLoading: false, offlineWarningVisible: false, openEmpty: false, publishReplyMock: vi.fn(), quoteInsertNumber: undefined as number | undefined, quoteInsertRequestId: 0, quoteInsertSelectedText: '', replyIndex: undefined as number | undefined, resetPublishReplyOptionsMock: vi.fn(), selectedText: 'selected text', setAccountMock: vi.fn(), setPublishReplyOptionsMock: vi.fn(), springStartMock: vi.fn(), subplebbits: { 'music-posting.eth': { address: 'music-posting.eth', }, } as Record, showUploadControls: true, uploadComplete: undefined as ((url: string) => void) | undefined, uploadedFileName: null as string | null, uploadMode: 'always', })); vi.mock('react-i18next', () => ({ Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record }) => createElement('span', { 'data-testid': `trans-${i18nKey}` }, `${i18nKey}:${JSON.stringify(values || {})}`), useTranslation: () => ({ t: (key: string, options?: Record) => { if (!options) { return key; } if (typeof options.no !== 'undefined') { return `${key}:${options.no}`; } if (typeof options.length !== 'undefined') { return `${key}:${options.length}`; } return `${key}:${JSON.stringify(options)}`; }, }), })); vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ setAccount: (account: unknown) => testState.setAccountMock(account), useAccount: () => testState.account, })); vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({ default: (selector: (state: { subplebbits: typeof testState.subplebbits }) => T) => selector({ subplebbits: testState.subplebbits, }), })); vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({ default: () => ({ isOffline: testState.offlineWarningVisible, isOnlineStatusLoading: testState.offlineStatusLoading, offlineTitle: testState.offlineTitle, }), })); vi.mock('../../../stores/use-selected-text-store', () => ({ default: () => ({ selectedText: testState.selectedText, }), })); vi.mock('../../../stores/use-reply-modal-store', () => ({ default: (selector?: (state: { openEmpty: boolean; quoteInsertNumber?: number; quoteInsertRequestId: number; quoteInsertSelectedText: string }) => T) => { const state = { openEmpty: testState.openEmpty, quoteInsertNumber: testState.quoteInsertNumber, quoteInsertRequestId: testState.quoteInsertRequestId, quoteInsertSelectedText: testState.quoteInsertSelectedText, }; return selector ? selector(state) : (state as T); }, })); vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({ getShowUploadControls: () => testState.showUploadControls, isWebRuntime: () => true, })); vi.mock('../../../stores/use-media-hosting-store', () => ({ default: (selector: (state: { uploadMode: string }) => unknown) => selector({ uploadMode: testState.uploadMode, }), })); vi.mock('../../../hooks/use-directories', () => ({ useDirectoryByAddress: (address: string) => testState.directoryByAddress[address], })); vi.mock('../../../hooks/use-publish-reply', () => ({ default: () => ({ publishReply: testState.publishReplyMock, replyIndex: testState.replyIndex, resetPublishReplyOptions: testState.resetPublishReplyOptionsMock, setPublishReplyOptions: (options: Record) => testState.setPublishReplyOptionsMock(options), }), })); vi.mock('../../../hooks/use-is-mobile', () => ({ default: () => testState.isMobile, })); vi.mock('../../../hooks/use-file-upload', () => ({ useFileUpload: ({ onUploadComplete }: { onUploadComplete: (url: string) => void }) => { testState.uploadComplete = onUploadComplete; return { handleUpload: testState.handleUploadMock, isUploading: testState.isUploading, uploadedFileName: testState.uploadedFileName, }; }, })); vi.mock('lodash/debounce', () => ({ default: void>(fn: T) => { const wrapped = ((...args: Parameters) => fn(...args)) as T & { cancel: () => void }; wrapped.cancel = () => undefined; return wrapped; }, })); vi.mock('@react-spring/web', async () => { const React = await vi.importActual('react'); return { animated: { div: React.forwardRef(({ style, ...props }: any, ref) => React.createElement('div', { ...props, ref, style: { touchAction: style?.touchAction } })), }, useSpring: () => [ { x: { get: () => 120 }, y: { get: () => 80 }, }, { start: testState.springStartMock, }, ], }; }); vi.mock('@use-gesture/react', () => ({ useDrag: () => () => ({}), })); let container: HTMLDivElement; let root: Root; const flushEffects = async (count = 4) => { for (let i = 0; i < count; i += 1) { await act(async () => { await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); }); } }; const renderReplyModal = async (initialEntry = '/mu/thread/post-1') => { await act(async () => { root.render( createElement( MemoryRouter, { initialEntries: [initialEntry] }, createElement(ReplyModal, { closeModal: testState.closeModalMock, parentCid: 'parent-cid', parentNumber: 42, postCid: 'post-cid', scrollY: 120, showReplyModal: true, subplebbitAddress: 'music-posting.eth', threadNumber: 42, }), ), ); }); await flushEffects(); }; const rerenderReplyModal = async (initialEntry = '/mu/thread/post-1') => { await renderReplyModal(initialEntry); }; const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => { await act(async () => { const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); descriptor?.set?.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); }); }; const clickButtonByText = async (text: string) => { const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); await act(async () => { button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); }; describe('ReplyModal', () => { beforeEach(() => { vi.clearAllMocks(); testState.account = { author: { displayName: 'Alice' } }; testState.closeModalMock.mockReset(); testState.directoryByAddress = { 'music-posting.eth': { address: 'music-posting.eth', features: {}, }, }; testState.handleUploadMock.mockReset(); testState.isMobile = false; testState.isUploading = false; testState.offlineTitle = ''; testState.offlineStatusLoading = false; testState.offlineWarningVisible = false; testState.openEmpty = false; testState.publishReplyMock.mockReset(); testState.quoteInsertNumber = undefined; testState.quoteInsertRequestId = 0; testState.quoteInsertSelectedText = ''; testState.replyIndex = undefined; testState.resetPublishReplyOptionsMock.mockReset(); testState.selectedText = 'selected text'; testState.setAccountMock.mockReset(); testState.setPublishReplyOptionsMock.mockReset(); testState.springStartMock.mockReset(); testState.subplebbits = { 'music-posting.eth': { address: 'music-posting.eth', }, }; testState.showUploadControls = true; testState.uploadComplete = undefined; testState.uploadedFileName = null; testState.uploadMode = 'always'; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); }); it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => { testState.offlineTitle = 'posts_last_synced_info:{"time":"ago:1000"}'; testState.offlineWarningVisible = true; await renderReplyModal('/mu/thread/post-1'); const nameInput = container.querySelectorAll('input[type="text"]')[0]; const linkInput = container.querySelectorAll('input[type="text"]')[1]; const textarea = container.querySelector('textarea'); expect(nameInput?.value).toBe('Alice'); expect(linkInput?.getAttribute('placeholder')).toContain('Link'); expect(textarea?.value).toBe('>>42\nselected text'); expect(container.textContent).toContain('choose_file'); expect(container.textContent).toContain('Spoiler?'); expect(container.textContent).toContain('posts_last_synced_info:{"time":"ago:1000"}'); expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: '>>42\nselected text' }); expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' }); }); it('does not render an offline warning when the shared offline hook reports the board as online', async () => { await renderReplyModal('/mu/thread/post-1'); expect(container.querySelector('[class*="offlineBoard"]')).toBeNull(); expect(container.textContent).not.toContain('subplebbit_offline_info'); }); it('validates empty and invalid replies, then publishes once the payload is valid', async () => { testState.openEmpty = true; testState.selectedText = ''; await renderReplyModal('/mu/thread/post-1'); await clickButtonByText('post'); expect(container.textContent).toContain('error: empty_comment_alert'); expect(testState.publishReplyMock).not.toHaveBeenCalled(); const linkInput = container.querySelectorAll('input[type="text"]')[1]; const spoilerCheckbox = container.querySelector('input[type="checkbox"]'); await dispatchInput(linkInput, 'not-a-url'); await act(async () => { spoilerCheckbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await clickButtonByText('post'); expect(container.textContent).toContain('error: invalid_url_alert'); expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ spoiler: true }); await dispatchInput(linkInput, 'https://example.com/file.png'); await clickButtonByText('post'); expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://example.com/file.png' }); expect(testState.publishReplyMock).toHaveBeenCalledTimes(1); }); it('updates account state, applies upload completions, and closes once publishing succeeds', async () => { await renderReplyModal('/mu/thread/post-1'); const nameInput = container.querySelectorAll('input[type="text"]')[0]; const linkInput = container.querySelectorAll('input[type="text"]')[1]; await dispatchInput(nameInput, 'Alicia'); expect(testState.setAccountMock).toHaveBeenCalledWith({ author: { displayName: 'Alicia' }, }); expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alicia' }); await act(async () => { testState.uploadComplete?.('https://cdn.example/uploaded.png'); }); expect(linkInput?.value).toBe('https://cdn.example/uploaded.png'); expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://cdn.example/uploaded.png' }); testState.replyIndex = 3; await rerenderReplyModal('/mu/thread/post-1'); expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1); expect(testState.closeModalMock).toHaveBeenCalledTimes(1); }); it('inserts quote requests only once and keeps the textarea content stable across rerenders', async () => { testState.isMobile = true; testState.openEmpty = true; testState.selectedText = 'Existing line'; await renderReplyModal('/mu/thread/post-1'); const textarea = container.querySelector('textarea'); expect(textarea?.value).toBe('Existing line'); testState.quoteInsertNumber = 77; testState.quoteInsertRequestId = 1; testState.quoteInsertSelectedText = 'Quoted line'; await rerenderReplyModal('/mu/thread/post-1'); expect(textarea?.value).toBe('Existing line\n>>77\nQuoted line\n'); expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'Existing line\n>>77\nQuoted line\n', }); await rerenderReplyModal('/mu/thread/post-1'); expect(textarea?.value).toBe('Existing line\n>>77\nQuoted line\n'); }); it('uses file-link placeholder defaults in all view and hides board-specific warnings or spoiler controls when disabled', async () => { testState.directoryByAddress = { 'music-posting.eth': { address: 'music-posting.eth', features: { noSpoilerReplies: true }, }, }; await renderReplyModal('/all/thread/post-1'); const linkInput = container.querySelectorAll('input[type="text"]')[1]; expect(linkInput?.getAttribute('placeholder')).toContain('Link_to_file'); expect(container.textContent).not.toContain('warning'); expect(container.textContent).not.toContain('Spoiler?'); }); });