diff --git a/src/components/board-header/__tests__/board-header.test.tsx b/src/components/board-header/__tests__/board-header.test.tsx new file mode 100644 index 00000000..c9bec4ea --- /dev/null +++ b/src/components/board-header/__tests__/board-header.test.tsx @@ -0,0 +1,196 @@ +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 BoardHeader from '../board-header'; + +(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(() => ({ + accountComment: undefined as { subplebbitAddress?: string } | undefined, + directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, + directoriesMetadata: { title: '/all/ - Directories' } as { title?: string } | undefined, + isMobile: false, + navigateMock: vi.fn(), + offlineIconClass: 'offline', + offlineTitle: 'Board offline', + resolvedAddress: 'music-posting.eth' as string | undefined, + shouldShowSnow: false, + stableSubplebbit: { + address: 'music-posting.eth', + shortAddress: 'music-posting.eth', + title: '/mu/ - Music', + } as { address?: string; shortAddress?: string; title?: string } | undefined, + subscriptionsCount: 2, + subplebbits: { + 'music-posting.eth': { address: 'music-posting.eth' }, + } as Record, + useIsSubplebbitOfflineValue: { + isOffline: false, + isOnlineStatusLoading: false, + offlineIconClass: 'offline', + offlineTitle: 'Board offline', + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => (options ? `${key}:${JSON.stringify(options)}` : key), + }), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => testState.navigateMock, + }; +}); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccountComment: () => testState.accountComment, +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/accounts', () => ({ + default: (selector: (state: { activeAccountId?: string; accounts: Record }) => unknown) => + selector({ + accounts: { + active: { + subscriptions: new Array(testState.subscriptionsCount).fill('sub'), + }, + }, + activeAccountId: 'active', + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({ + default: (selector: (state: { subplebbits: typeof testState.subplebbits }) => unknown) => + selector({ + subplebbits: testState.subplebbits, + }), +})); + +vi.mock('../../../hooks/use-stable-subplebbit', () => ({ + useStableSubplebbit: () => testState.stableSubplebbit, +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, + useDirectoriesMetadata: () => testState.directoriesMetadata, +})); + +vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ + useResolvedSubplebbitAddress: () => testState.resolvedAddress, +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({ + default: () => testState.useIsSubplebbitOfflineValue, +})); + +vi.mock('../../../lib/snow', () => ({ + shouldShowSnow: () => testState.shouldShowSnow, +})); + +vi.mock('../../tooltip', () => ({ + default: ({ content, children }: { content: string; children: React.ReactNode }) => + createElement('span', { 'data-testid': 'tooltip', 'data-content': content }, children), +})); + +vi.mock('../../../generated/asset-manifest', () => ({ + BANNERS: ['banner-a.png', 'banner-b.png'], +})); + +let container: HTMLDivElement; +let root: Root; + +const renderHeader = async (initialEntry: string) => { + await act(async () => { + root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(BoardHeader))); + }); +}; + +describe('BoardHeader', () => { + let mathRandomSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + testState.accountComment = undefined; + testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; + testState.directoriesMetadata = { title: '/all/ - Directories' }; + testState.isMobile = false; + testState.navigateMock.mockReset(); + testState.offlineIconClass = 'offline'; + testState.offlineTitle = 'Board offline'; + testState.resolvedAddress = 'music-posting.eth'; + testState.shouldShowSnow = false; + testState.stableSubplebbit = { + address: 'music-posting.eth', + shortAddress: 'music-posting.eth', + title: '/mu/ - Music', + }; + testState.subscriptionsCount = 2; + testState.subplebbits = { + 'music-posting.eth': { address: 'music-posting.eth' }, + }; + testState.useIsSubplebbitOfflineValue = { + isOffline: false, + isOnlineStatusLoading: false, + offlineIconClass: 'offline', + offlineTitle: 'Board offline', + }; + mathRandomSpy = vi.spyOn(Math, 'random').mockReturnValue(0); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + mathRandomSpy.mockRestore(); + }); + + it('renders the all view title and banner chrome on desktop', async () => { + await renderHeader('/all'); + + expect(container.textContent).toContain('/all/ - Directories'); + expect(container.querySelector('img')?.getAttribute('src')).toBe('banner-a.png'); + expect(container.textContent).not.toContain('subscriptions_subtitle'); + }); + + it('renders a clickable subscriptions subtitle that navigates to subscription settings', async () => { + await renderHeader('/subs'); + + expect(container.textContent).toContain('/subs/ - Subscriptions'); + expect(container.textContent).toContain('subscriptions_subtitle:{"count":2}'); + + const clickableSubtitle = container.querySelector('[role="button"]'); + await act(async () => { + clickableSubtitle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#subscriptions-settings'); + }); + + it('renders the board title, address subtitle, and offline indicator for board routes', async () => { + testState.useIsSubplebbitOfflineValue = { + isOffline: true, + isOnlineStatusLoading: false, + offlineIconClass: 'offline', + offlineTitle: 'Board offline', + }; + + await renderHeader('/mu'); + + expect(container.textContent).toContain('/mu/ - Music'); + expect(container.textContent).toContain('music-posting.eth'); + expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('data-content')).toBe('Board offline'); + expect(container.querySelector('img')?.getAttribute('src')).toBe('banner-a.png'); + }); +}); diff --git a/src/components/catalog-filters/__tests__/catalog-filters.test.tsx b/src/components/catalog-filters/__tests__/catalog-filters.test.tsx new file mode 100644 index 00000000..59688007 --- /dev/null +++ b/src/components/catalog-filters/__tests__/catalog-filters.test.tsx @@ -0,0 +1,274 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import CatalogFilters from '../catalog-filters'; + +(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; + +type FilterItem = { + color?: string; + count: number; + enabled: boolean; + filteredCids: Set; + hide: boolean; + subplebbitCounts: Map; + subplebbitFilteredCids: Map>; + text: string; + top: boolean; +}; + +const testState = vi.hoisted(() => ({ + currentSubplebbitAddress: 'music-posting.eth' as string | null, + filterItems: [] as FilterItem[], + resetCountsMock: vi.fn(), + resetFeedMock: vi.fn(), + saveAndApplyFiltersMock: vi.fn(), +})); + +const createFilterItem = (overrides: Partial = {}): FilterItem => ({ + color: '', + count: 0, + enabled: true, + filteredCids: new Set(), + hide: true, + subplebbitCounts: new Map(), + subplebbitFilteredCids: new Map>(), + text: '', + top: false, + ...overrides, +}); + +function getCatalogFiltersState() { + return { + currentSubplebbitAddress: testState.currentSubplebbitAddress, + filterItems: testState.filterItems, + saveAndApplyFilters: testState.saveAndApplyFiltersMock, + }; +} + +function useCatalogFiltersStoreMock(selector?: (state: ReturnType) => T) { + const state = getCatalogFiltersState(); + return selector ? selector(state) : (state as T); +} + +useCatalogFiltersStoreMock.getState = () => ({ + resetCountsForCurrentSubplebbit: testState.resetCountsMock, +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('../../../stores/use-catalog-filters-store', () => ({ + default: useCatalogFiltersStoreMock, +})); + +vi.mock('../../../stores/use-feed-reset-store', () => ({ + default: (selector: (state: { reset: typeof testState.resetFeedMock }) => unknown) => + selector({ + reset: testState.resetFeedMock, + }), +})); + +vi.mock('../filters-protip', () => ({ + default: () => createElement('div', { 'data-testid': 'filters-protip' }, 'filters-protip'), +})); + +vi.mock('../highlight-color-picker', () => ({ + default: ({ index, item, updateLocalFilterItem }: { index: number; item: FilterItem; updateLocalFilterItem: (index: number, item: FilterItem) => void }) => + createElement( + 'button', + { + 'data-testid': `color-picker-${index}`, + onClick: () => updateLocalFilterItem(index, { ...item, color: index === 0 ? 'red' : 'blue' }), + type: 'button', + }, + item.color || 'pick', + ), +})); + +let container: HTMLDivElement; +let root: Root; + +const click = async (element: Element | null) => { + await act(async () => { + element?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +const dispatchInput = async (element: HTMLInputElement, value: string) => { + await act(async () => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }); +}; + +const toggleCheckbox = async (element: HTMLInputElement | undefined) => { + await act(async () => { + element?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +const renderCatalogFilters = () => { + act(() => { + root.render(createElement(CatalogFilters)); + }); +}; + +const openModal = async () => { + const button = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => candidate.textContent === 'filters'); + await click(button ?? null); +}; + +const getRows = () => Array.from(container.querySelectorAll('tbody tr')); + +const getTextInputs = () => Array.from(container.querySelectorAll('tbody input[type="text"]')); + +describe('CatalogFilters', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + testState.currentSubplebbitAddress = 'music-posting.eth'; + testState.filterItems = [ + createFilterItem({ + count: 2, + subplebbitCounts: new Map([['music-posting.eth', 2]]), + subplebbitFilteredCids: new Map([['music-posting.eth', new Set(['alpha-cid'])]]), + text: 'alpha', + }), + createFilterItem({ + count: 4, + hide: false, + subplebbitCounts: new Map([['music-posting.eth', 4]]), + subplebbitFilteredCids: new Map([['music-posting.eth', new Set(['beta-cid'])]]), + text: 'beta', + top: true, + }), + ]; + testState.resetCountsMock.mockReset(); + testState.resetFeedMock.mockReset(); + testState.saveAndApplyFiltersMock.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + vi.useRealTimers(); + act(() => root.unmount()); + container.remove(); + }); + + it('opens help, closes help from the overlay, and closes the modal with Escape', async () => { + renderCatalogFilters(); + await openModal(); + + expect(container.textContent).toContain('filter_and_highlights'); + + await click(container.querySelector('[title="help"]')); + + expect(container.querySelector('[data-testid="filters-protip"]')?.textContent).toBe('filters-protip'); + expect(container.textContent).toContain('filter_and_highlights_help'); + + const overlay = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => candidate.tagName === 'DIV'); + await click(overlay ?? null); + + expect(container.querySelector('[data-testid="filters-protip"]')).toBeNull(); + expect(container.textContent).toContain('filter_and_highlights'); + + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })); + }); + + expect(container.querySelector('[title="close"]')).toBeNull(); + }); + + it('adds a filter row with default empty values', async () => { + vi.useFakeTimers(); + renderCatalogFilters(); + await openModal(); + + expect(getTextInputs()).toHaveLength(2); + + const addButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'add'); + await act(async () => { + addButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + vi.runAllTimers(); + }); + + const inputs = getTextInputs(); + expect(inputs).toHaveLength(3); + expect(inputs[2]?.value).toBe(''); + + const addedRowInputs = Array.from(getRows()[2]?.querySelectorAll('input') ?? []); + expect(addedRowInputs[0]?.checked).toBe(true); + expect(addedRowInputs[2]?.checked).toBe(true); + expect(addedRowInputs[3]?.checked).toBe(false); + }); + + it('reorders, edits, and saves non-empty filters via the document Enter shortcut', async () => { + renderCatalogFilters(); + await openModal(); + + expect(container.textContent).toContain('x2'); + expect(container.textContent).toContain('x4'); + + const initialRows = getRows(); + const moveUpButton = initialRows[1]?.querySelector('[role="button"]'); + await click(moveUpButton ?? null); + + const reorderedInputs = getTextInputs(); + expect(reorderedInputs[0]?.value).toBe('beta'); + expect(reorderedInputs[1]?.value).toBe('alpha'); + + const deleteButton = getRows()[1]?.querySelectorAll('[role="button"]')[1]; + await click(deleteButton ?? null); + + expect(getTextInputs()).toHaveLength(1); + + const firstRow = getRows()[0]; + const firstRowInputs = firstRow ? Array.from(firstRow.querySelectorAll('input')) : []; + const enabledCheckbox = firstRowInputs[0]; + const textInput = firstRowInputs[1]; + const hideCheckbox = firstRowInputs[2]; + const topCheckbox = firstRowInputs[3]; + + expect(textInput?.value).toBe('beta'); + + await toggleCheckbox(enabledCheckbox); + await click(container.querySelector('[data-testid="color-picker-0"]')); + await toggleCheckbox(hideCheckbox); + await toggleCheckbox(topCheckbox); + await dispatchInput(textInput, 'beta-updated'); + + const addButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'add'); + await click(addButton ?? null); + + expect(getTextInputs()).toHaveLength(2); + + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); + }); + + expect(testState.saveAndApplyFiltersMock).toHaveBeenCalledTimes(1); + const savedFilters = testState.saveAndApplyFiltersMock.mock.calls[0]?.[0] as FilterItem[] | undefined; + expect(savedFilters).toHaveLength(1); + expect(savedFilters?.[0]).toMatchObject({ + color: 'red', + enabled: false, + hide: true, + text: 'beta-updated', + top: false, + }); + expect(savedFilters?.[0]).not.toHaveProperty('id'); + expect(testState.resetCountsMock).toHaveBeenCalledTimes(1); + expect(testState.resetFeedMock).toHaveBeenCalledTimes(1); + expect(container.querySelector('[title="close"]')).toBeNull(); + }); +}); diff --git a/src/components/catalog-row/__tests__/catalog-row.test.tsx b/src/components/catalog-row/__tests__/catalog-row.test.tsx new file mode 100644 index 00000000..b0d7970e --- /dev/null +++ b/src/components/catalog-row/__tests__/catalog-row.test.tsx @@ -0,0 +1,349 @@ +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 CatalogRow, { CatalogPostMedia } from '../catalog-row'; + +(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; + +type TestComment = { + author?: { + address?: string; + displayName?: string; + }; + cid: string; + content?: string; + link?: string; + linkHeight?: number; + linkWidth?: number; + locked?: boolean; + parentCid?: string; + pinned?: boolean; + postCid?: string; + removed?: boolean; + replyCount?: number; + spoiler?: boolean; + subplebbitAddress?: string; + thumbnailUrl?: string; + timestamp?: number; + title?: string; +}; + +const testState = vi.hoisted(() => ({ + directories: [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }] as Array<{ + address: string; + features?: Record; + title?: string; + }>, + gifFrameStatus: 'idle' as 'failed' | 'idle' | 'ready', + gifFrameUrl: undefined as string | undefined, + hiddenCids: new Set(), + imageSize: 'Small' as 'Large' | 'Small', + linkCount: 0, + matchedFilters: new Map(), + mediaInfoByLink: {} as Record, + replies: [] as TestComment[], + roleByAddress: {} as Record, + showOPComment: true, + showSnow: false, +})); + +function getCatalogFiltersState() { + return { + matchedFilters: testState.matchedFilters, + }; +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useReplies: ({ comment }: { comment?: TestComment }) => ({ + replies: comment ? testState.replies : [], + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({ + default: { + createInstance: () => ({ + entries: vi.fn().mockResolvedValue([]), + getItem: vi.fn(), + removeItem: vi.fn(), + setItem: vi.fn(), + }), + }, +})); + +vi.mock('@floating-ui/react', () => ({ + offset: () => ({}), + size: () => ({}), + useFloating: () => ({ + floatingStyles: {}, + refs: { + floating: { current: null }, + reference: { current: null }, + setFloating: () => undefined, + setReference: () => undefined, + }, + update: () => undefined, + }), +})); + +vi.mock('../../../lib/get-short-address', () => ({ + default: () => 'mu', +})); + +vi.mock('../../../lib/snow', () => ({ + shouldShowSnow: () => testState.showSnow, +})); + +vi.mock('../../../lib/utils/time-utils', () => ({ + getFormattedTimeAgo: (timestamp?: number) => `ago:${timestamp}`, +})); + +vi.mock('../../../lib/utils/post-menu-props', () => ({ + selectPostMenuProps: (post?: TestComment) => ({ cid: post?.cid }), +})); + +vi.mock('../../../hooks/use-directories', () => ({ + findDirectoryByAddress: (directories: typeof testState.directories, address?: string) => directories.find((entry) => entry.address === address), + normalizeBoardAddress: (address: string) => address, + useDirectories: () => testState.directories, +})); + +vi.mock('../../../stores/use-catalog-filters-store', () => ({ + default: (selector?: (state: ReturnType) => T) => { + const state = getCatalogFiltersState(); + return selector ? selector(state) : (state as T); + }, +})); + +vi.mock('../../../stores/use-catalog-style-store', () => ({ + default: () => ({ + imageSize: testState.imageSize, + showOPComment: testState.showOPComment, + }), +})); + +vi.mock('../../../hooks/use-author-privileges', () => ({ + default: ({ commentAuthorAddress }: { commentAuthorAddress?: string }) => + testState.roleByAddress[commentAuthorAddress || ''] || { + commentAuthorRole: undefined, + isCommentAuthorMod: false, + }, +})); + +vi.mock('../../../hooks/use-comment-media-info', () => ({ + useCommentMediaInfo: (link?: string) => (link ? testState.mediaInfoByLink[link] : undefined), +})); + +vi.mock('../../../hooks/use-count-links-in-replies', () => ({ + default: () => testState.linkCount, +})); + +vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({ + default: () => ({ + frameUrl: testState.gifFrameUrl, + status: testState.gifFrameStatus, + }), +})); + +vi.mock('../../../hooks/use-hide', () => ({ + default: ({ cid }: { cid: string }) => ({ + hidden: testState.hiddenCids.has(cid), + }), +})); + +vi.mock('../../post-desktop/post-menu-desktop', () => ({ + default: ({ postMenu }: { postMenu: { cid?: string } }) => createElement('span', { 'data-testid': `post-menu-${postMenu.cid}` }, 'menu'), +})); + +let container: HTMLDivElement; +let root: Root; + +const flushEffects = async (count = 3) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +}; + +const renderWithRouter = async (element: React.ReactNode, initialEntry = '/mu/catalog') => { + await act(async () => { + root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, element)); + }); + await flushEffects(); +}; + +describe('CatalogRow', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + testState.directories = [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }]; + testState.gifFrameStatus = 'idle'; + testState.gifFrameUrl = undefined; + testState.hiddenCids = new Set(); + testState.imageSize = 'Small'; + testState.linkCount = 0; + testState.matchedFilters = new Map(); + testState.mediaInfoByLink = {}; + testState.replies = []; + testState.roleByAddress = {}; + testState.showOPComment = true; + testState.showSnow = false; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + vi.useRealTimers(); + act(() => root.unmount()); + container.remove(); + }); + + it('renders gif frames with matched filter borders and falls back to deleted media on load errors', async () => { + testState.gifFrameStatus = 'ready'; + testState.gifFrameUrl = 'https://cdn.example/frame.png'; + testState.matchedFilters = new Map([['post-1', 'red']]); + + await act(async () => { + root.render( + createElement(CatalogPostMedia, { + cid: 'post-1', + commentMediaInfo: { type: 'gif', url: 'https://example.com/source.gif' }, + linkHeight: 200, + linkWidth: 400, + }), + ); + }); + + const wrapper = container.firstElementChild as HTMLElement | null; + const frameImage = container.querySelector('img[src="https://cdn.example/frame.png"]'); + + expect(wrapper?.style.border).toContain('red'); + expect(frameImage).toBeTruthy(); + + await act(async () => { + frameImage?.dispatchEvent(new Event('error', { bubbles: true })); + }); + + expect(container.querySelector('img[src="assets/filedeleted-res.gif"]')).toBeTruthy(); + }); + + it('renders audio players and video first-frame fallbacks for media without thumbnails', async () => { + await act(async () => { + root.render( + createElement(CatalogPostMedia, { + cid: 'audio-post', + commentMediaInfo: { type: 'audio', url: 'https://example.com/file.mp3' }, + }), + ); + }); + + expect(container.querySelector('audio[src="https://example.com/file.mp3"]')).toBeTruthy(); + + await act(async () => { + root.render( + createElement(CatalogPostMedia, { + cid: 'video-post', + commentMediaInfo: { type: 'video', url: 'https://example.com/file.mp4' }, + }), + ); + }); + + expect(container.querySelector('video[src="https://example.com/file.mp4#t=0.001"]')).toBeTruthy(); + }); + + it('renders media posts with board links, counts, and hover previews in all view', async () => { + testState.directories = [{ address: 'music-posting.eth', features: { requirePostLinkIsMedia: true }, title: '/mu/ - Music' }]; + testState.linkCount = 2; + testState.mediaInfoByLink['https://example.com/media.png'] = { type: 'image', url: 'https://example.com/media.png' }; + testState.replies = [ + { + author: { address: 'author-2', displayName: 'Bob' }, + cid: 'reply-1', + timestamp: 200, + }, + ]; + testState.roleByAddress = { + 'author-1': { commentAuthorRole: 'Owner', isCommentAuthorMod: true }, + 'author-2': { commentAuthorRole: 'Janitor', isCommentAuthorMod: true }, + }; + + const post: TestComment = { + author: { address: 'author-1', displayName: 'Alice' }, + cid: 'post-1', + content: 'Hello **world**', + link: 'https://example.com/media.png', + linkHeight: 120, + linkWidth: 200, + locked: true, + pinned: true, + replyCount: 5, + subplebbitAddress: 'music-posting.eth', + timestamp: 100, + title: 'Thread title', + }; + + await renderWithRouter(createElement(CatalogRow, { row: [post] }), '/all/catalog'); + vi.useFakeTimers(); + + const postLink = document.body.querySelector('a[href="/mu/thread/post-1"]'); + expect(postLink).toBeTruthy(); + expect(container.textContent).toContain('R: 5'); + expect(container.textContent).toContain('/ I: 2'); + expect(container.querySelector('[data-testid="post-menu-post-1"]')?.textContent).toBe('menu'); + + const previewTrigger = document.body.querySelector('a[href="/mu/thread/post-1"] > div'); + await act(async () => { + previewTrigger?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + vi.advanceTimersByTime(260); + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain('Thread title by Alice ## Board Owner'); + expect(document.body.textContent).toContain('to p/mu'); + expect(document.body.textContent).toContain('last_reply_by Bob ## Board Janitor'); + expect(document.body.textContent).toContain('ago:100'); + expect(document.body.textContent).toContain('ago:200'); + }); + + it('renders hidden and text-only threads with canonical board thread links', async () => { + testState.hiddenCids = new Set(['hidden-1']); + testState.showOPComment = false; + + const posts: TestComment[] = [ + { + author: { address: 'hidden-author', displayName: 'Ghost' }, + cid: 'hidden-1', + content: 'hidden text', + link: 'https://example.com/hidden.png', + subplebbitAddress: 'music-posting.eth', + }, + { + author: { address: 'text-author', displayName: 'Anon' }, + cid: 'text-1', + content: 'Plain thread body', + replyCount: 1, + subplebbitAddress: 'music-posting.eth', + title: 'Text title', + }, + ]; + + await renderWithRouter(createElement(CatalogRow, { row: posts }), '/mu/catalog'); + + const links = Array.from(document.body.querySelectorAll('a')).map((link) => link.getAttribute('href')); + expect(links).toContain('/mu/thread/hidden-1'); + expect(links).toContain('/mu/thread/text-1'); + expect(container.textContent).toContain('(hidden)'); + expect(container.textContent).toContain('Text title: Plain thread body'); + }); +}); diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx new file mode 100644 index 00000000..18659913 --- /dev/null +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -0,0 +1,376 @@ +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(), + currentTime: 10_000, + directoryByAddress: { + 'music-posting.eth': { + address: 'music-posting.eth', + features: {}, + }, + } as Record }>, + handleUploadMock: vi.fn(), + isMobile: false, + isUploading: 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(), + showUploadControls: true, + updatedAt: undefined as number | undefined, + 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('../../../hooks/use-stable-subplebbit', () => ({ + useSubplebbitField: () => testState.updatedAt, +})); + +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-current-time', () => ({ + useCurrentTime: () => testState.currentTime, +})); + +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: () => () => ({}), +})); + +vi.mock('../../../lib/utils/time-utils', () => ({ + getFormattedTimeAgo: (time: number) => `ago:${time}`, +})); + +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.currentTime = 10_000; + testState.directoryByAddress = { + 'music-posting.eth': { + address: 'music-posting.eth', + features: {}, + }, + }; + testState.handleUploadMock.mockReset(); + testState.isMobile = false; + testState.isUploading = 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.showUploadControls = true; + testState.updatedAt = undefined; + 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 offline warning on board routes', async () => { + testState.updatedAt = 1_000; + + 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('warning'); + 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('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?'); + }); +});