diff --git a/src/components/embed/__tests__/embed.test.tsx b/src/components/embed/__tests__/embed.test.tsx new file mode 100644 index 00000000..bad65953 --- /dev/null +++ b/src/components/embed/__tests__/embed.test.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Embed, { canEmbed } from '../embed'; + +(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; + +let container: HTMLDivElement; +let root: Root; + +const renderEmbed = async (url: string) => { + await act(async () => { + root.render(createElement(Embed, { url })); + }); +}; + +describe('Embed', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders youtube and twitch embeds with the expected iframe sources', async () => { + await renderEmbed('https://youtu.be/video123'); + + expect(container.querySelector('iframe')?.getAttribute('src')).toBe('https://www.youtube.com/embed/video123'); + + await renderEmbed('https://www.twitch.tv/videos/987654321'); + + expect(container.querySelector('iframe')?.getAttribute('src')).toContain('video=987654321'); + expect(container.querySelector('iframe')?.getAttribute('src')).toContain(`parent=${window.location.hostname}`); + }); + + it('renders x and reddit embeds through iframe markup and leaves unsupported urls empty', async () => { + await renderEmbed('https://x.com/test/status/123'); + + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain('twitter-tweet'); + + await renderEmbed('https://www.reddit.com/r/test/comments/abc123/example/'); + + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain('reddit-embed-bq'); + + await renderEmbed('https://example.com/plain-link'); + + expect(container.innerHTML).toBe(''); + }); + + it('reports embeddable hosts through canEmbed and rejects unsupported reddit pages', () => { + expect(canEmbed(new URL('https://www.youtube.com/watch?v=abc123'))).toBe(true); + expect(canEmbed(new URL('https://yt.example/watch?v=abc123'))).toBe(true); + expect(canEmbed(new URL('https://www.reddit.com/r/test/comments/abc123/example/'))).toBe(true); + expect(canEmbed(new URL('https://www.reddit.com/r/test/'))).toBe(false); + expect(canEmbed(new URL('https://example.com/plain-link'))).toBe(false); + }); +}); diff --git a/src/components/loading-ellipsis/__tests__/loading-ellipsis.test.tsx b/src/components/loading-ellipsis/__tests__/loading-ellipsis.test.tsx new file mode 100644 index 00000000..082c4f27 --- /dev/null +++ b/src/components/loading-ellipsis/__tests__/loading-ellipsis.test.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import LoadingEllipsis from '../loading-ellipsis'; + +(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; + +let container: HTMLDivElement; +let root: Root; + +describe('LoadingEllipsis', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('keeps the last word inside the nowrap span and preserves the prefix text', () => { + act(() => { + root.render(createElement(LoadingEllipsis, { string: 'Downloading board' })); + }); + + const spans = container.querySelectorAll('span'); + expect(container.textContent).toBe('Downloading board'); + expect(spans[1]?.textContent).toBe('board'); + expect(spans[2]).toBeTruthy(); + }); + + it('renders single-word strings without a leading space', () => { + act(() => { + root.render(createElement(LoadingEllipsis, { string: 'Loading' })); + }); + + expect(container.textContent).toBe('Loading'); + expect(container.firstChild?.textContent).toBe('Loading'); + }); +}); diff --git a/src/components/markdown/__tests__/markdown.test.tsx b/src/components/markdown/__tests__/markdown.test.tsx new file mode 100644 index 00000000..92a4d5e2 --- /dev/null +++ b/src/components/markdown/__tests__/markdown.test.tsx @@ -0,0 +1,237 @@ +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 Markdown from '../markdown'; + +(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 = { + cid?: string; + number?: number; +}; + +const testState = vi.hoisted(() => ({ + comments: {} as Record, + embeddableHosts: new Set(), + internalPathByHref: {} as Record, + isMobile: false, + mediaInfoByHref: {} as Record, + numberToCid: {} as Record>, + unavailableCids: new Set(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@floating-ui/react', () => ({ + FloatingPortal: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children), + autoUpdate: () => undefined, + offset: () => ({}), + shift: () => ({}), + size: () => ({}), + useDismiss: () => ({}), + useFloating: () => ({ + context: {}, + floatingStyles: {}, + refs: { + setFloating: () => undefined, + setReference: () => undefined, + }, + update: () => undefined, + }), + useFocus: () => ({}), + useHover: () => ({}), + useInteractions: () => ({ + getFloatingProps: (props?: Record) => props || {}, + getReferenceProps: (props?: Record) => props || {}, + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.comments[commentCid] : undefined), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({ + default: (selector: (state: { comments: typeof testState.comments }) => unknown) => + selector({ + comments: testState.comments, + }), +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../../lib/utils/media-utils', () => ({ + getHasThumbnail: (linkMediaInfo?: { thumbnail?: string; type?: string }, href?: string) => + Boolean(linkMediaInfo?.thumbnail || linkMediaInfo?.type === 'image' || (href && testState.mediaInfoByHref[href]?.thumbnail)), + getLinkMediaInfo: (href: string) => testState.mediaInfoByHref[href], +})); + +vi.mock('../../../lib/utils/quote-link-utils', () => ({ + isUnavailableQuoteTarget: (comment?: TestComment) => Boolean(comment?.cid && testState.unavailableCids.has(comment.cid)), +})); + +vi.mock('../../../lib/utils/view-utils', () => ({ + isCatalogView: (pathname: string) => pathname.includes('/catalog'), +})); + +vi.mock('../../../lib/utils/url-utils', () => ({ + is5chanLink: (href: string) => href in testState.internalPathByHref, + isValidCrossboardPattern: (pattern: string) => pattern.startsWith('>>>/'), + transform5chanLinkToInternal: (href: string) => testState.internalPathByHref[href] ?? null, +})); + +vi.mock('../../../stores/use-post-number-store', () => ({ + default: (selector: (state: { numberToCid: typeof testState.numberToCid }) => unknown) => + selector({ + numberToCid: testState.numberToCid, + }), +})); + +vi.mock('../../comment-media', () => ({ + default: ({ commentMediaInfo }: { commentMediaInfo?: { type?: string; url?: string } }) => + createElement('div', { 'data-testid': 'comment-media' }, `${commentMediaInfo?.type}:${commentMediaInfo?.url}`), +})); + +vi.mock('../../embed', () => ({ + canEmbed: (parsedUrl: URL) => testState.embeddableHosts.has(parsedUrl.host), +})); + +vi.mock('../../reply-quote-preview', () => ({ + default: ({ + isOP, + isQuotelinkUnavailable, + quotelinkNumber, + quotelinkReply, + }: { + isOP?: boolean; + isQuotelinkUnavailable?: boolean; + quotelinkNumber?: number; + quotelinkReply?: TestComment; + }) => + createElement( + 'span', + { + 'data-number': String(quotelinkNumber ?? ''), + 'data-op': String(Boolean(isOP)), + 'data-testid': 'reply-quote-preview', + 'data-unavailable': String(Boolean(isQuotelinkUnavailable)), + }, + quotelinkReply?.cid || 'missing', + ), +})); + +let container: HTMLDivElement; +let root: Root; + +const renderMarkdown = async (props: { content: string; postCid?: string; subplebbitAddress?: string; title?: string }, initialEntry = '/mu/thread/post-1') => { + await act(async () => { + root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(Markdown, props))); + }); +}; + +describe('Markdown', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.comments = {}; + testState.embeddableHosts = new Set(); + testState.internalPathByHref = {}; + testState.isMobile = false; + testState.mediaInfoByHref = {}; + testState.numberToCid = {}; + testState.unavailableCids = new Set(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders catalog prefixes, greentext, spoilers, and cross-board/internal links', async () => { + testState.internalPathByHref = { + 'https://5chan.local/p/mu': '/mu', + }; + + await renderMarkdown( + { + content: '>green line\n[spoiler]spoiled[/spoiler] >>>/fit/ https://5chan.local/p/mu', + title: 'Subject', + }, + '/mu/catalog', + ); + + expect(container.textContent).toContain('Subject:'); + expect(container.querySelector('.greentext')?.textContent).toContain('>green line'); + expect(container.querySelector('.spoilertext')?.textContent).toBe('spoiled'); + + const links = Array.from(container.querySelectorAll('a')); + expect(links.map((link) => link.getAttribute('href'))).toEqual(expect.arrayContaining(['/fit', '/mu'])); + expect(links.find((link) => link.getAttribute('href') === '/fit')?.textContent).toBe('>>>/fit/'); + }); + + it('renders number quote links with op and unavailable state derived from cached comments', async () => { + testState.comments = { + 'comment-42': { cid: 'comment-42', number: 42 }, + }; + testState.numberToCid = { + 'music-posting.eth': { + 42: 'comment-42', + }, + }; + testState.unavailableCids = new Set(['comment-42']); + + await renderMarkdown({ + content: '>>42', + postCid: 'comment-42', + subplebbitAddress: 'music-posting.eth', + }); + + const quotePreview = container.querySelector('[data-testid="reply-quote-preview"]'); + expect(quotePreview?.getAttribute('data-number')).toBe('42'); + expect(quotePreview?.getAttribute('data-op')).toBe('true'); + expect(quotePreview?.getAttribute('data-unavailable')).toBe('true'); + expect(quotePreview?.textContent).toBe('comment-42'); + }); + + it('toggles inline media embeds for embeddable links outside catalog view', async () => { + testState.mediaInfoByHref = { + 'https://cdn.example/image.png': { + thumbnail: 'https://cdn.example/thumb.png', + type: 'image', + url: 'https://cdn.example/image.png', + }, + }; + + await renderMarkdown({ + content: 'https://cdn.example/image.png', + }); + + const toggle = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'embed'); + expect(toggle).toBeTruthy(); + expect(container.querySelector('[data-testid="comment-media"]')).toBeNull(); + + await act(async () => { + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[data-testid="comment-media"]')?.textContent).toBe('image:https://cdn.example/image.png'); + + const removeToggle = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'remove'); + await act(async () => { + removeToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[data-testid="comment-media"]')).toBeNull(); + }); +}); diff --git a/src/components/not-found-image/__tests__/not-found-image.test.tsx b/src/components/not-found-image/__tests__/not-found-image.test.tsx new file mode 100644 index 00000000..4ab520ec --- /dev/null +++ b/src/components/not-found-image/__tests__/not-found-image.test.tsx @@ -0,0 +1,58 @@ +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 NotFoundImage from '../not-found-image'; + +(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; + +vi.mock('../../../generated/asset-manifest', () => ({ + NOT_FOUND_IMAGES: ['/missing-1.png', '/missing-2.png', '/missing-3.png'], +})); + +let container: HTMLDivElement; +let root: Root; +let randomSpy: ReturnType; + +describe('NotFoundImage', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + randomSpy = vi.spyOn(Math, 'random'); + }); + + afterEach(() => { + randomSpy.mockRestore(); + act(() => root.unmount()); + container.remove(); + }); + + it('picks a deterministic image on mount and keeps it stable across rerenders', () => { + randomSpy.mockReturnValueOnce(0); + + act(() => { + root.render(createElement(NotFoundImage)); + }); + + expect(container.querySelector('img')?.getAttribute('src')).toBe('/missing-1.png'); + + randomSpy.mockReturnValueOnce(0.99); + act(() => { + root.render(createElement(NotFoundImage)); + }); + + expect(container.querySelector('img')?.getAttribute('src')).toBe('/missing-1.png'); + }); + + it('can pick a later image when mounted with a different random value', () => { + randomSpy.mockReturnValueOnce(0.99); + + act(() => { + root.render(createElement(NotFoundImage)); + }); + + expect(container.querySelector('img')?.getAttribute('src')).toBe('/missing-3.png'); + }); +}); diff --git a/src/components/post-desktop/post-menu-desktop/__tests__/post-menu-desktop.test.tsx b/src/components/post-desktop/post-menu-desktop/__tests__/post-menu-desktop.test.tsx new file mode 100644 index 00000000..30f39cba --- /dev/null +++ b/src/components/post-desktop/post-menu-desktop/__tests__/post-menu-desktop.test.tsx @@ -0,0 +1,224 @@ +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 PostMenuDesktop from '../post-menu-desktop'; + +(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(() => ({ + boardPath: 'mu', + copyShareLinkMock: vi.fn().mockResolvedValue(undefined), + copyToClipboardMock: vi.fn().mockResolvedValue(undefined), + hidden: false, + hideMock: vi.fn(), + mediaInfo: undefined as { thumbnail?: string; type?: string; url?: string } | undefined, + unhideMock: vi.fn(), + validUrl: true, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +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', () => ({ + FloatingFocusManager: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children), + autoUpdate: () => undefined, + flip: () => ({}), + offset: () => ({}), + shift: () => ({}), + useClick: () => ({}), + useDismiss: () => ({}), + useFloating: () => ({ + context: {}, + floatingStyles: {}, + refs: { + setFloating: () => undefined, + setReference: () => undefined, + }, + }), + useId: () => 'desktop-menu', + useInteractions: () => ({ + getFloatingProps: (props?: Record) => props || {}, + getReferenceProps: (props?: Record) => props || {}, + }), + useRole: () => ({}), +})); + +vi.mock('../../../../lib/utils/media-utils', () => ({ + getCommentMediaInfo: () => testState.mediaInfo, +})); + +vi.mock('../../../../lib/utils/url-utils', () => ({ + copyShareLinkToClipboard: testState.copyShareLinkMock, + isValidURL: () => testState.validUrl, +})); + +vi.mock('../../../../lib/utils/clipboard-utils', () => ({ + copyToClipboard: testState.copyToClipboardMock, +})); + +vi.mock('../../../../lib/utils/route-utils', () => ({ + getBoardPath: () => testState.boardPath, +})); + +vi.mock('../../../../hooks/use-directories', () => ({ + useDirectories: () => [], +})); + +vi.mock('../../../../hooks/use-hide', () => ({ + default: () => ({ + hidden: testState.hidden, + hide: testState.hideMock, + unhide: testState.unhideMock, + }), +})); + +vi.mock('../../../../lib/utils/view-utils', () => ({ + isAllView: (pathname: string) => pathname.startsWith('/all'), + isCatalogView: (pathname: string) => pathname.includes('/catalog'), + isPostPageView: (pathname: string) => pathname.includes('/thread/'), + isSubscriptionsView: (pathname: string) => pathname.startsWith('/subscriptions'), +})); + +let container: HTMLDivElement; +let root: Root; + +const basePostMenu = { + authorAddress: '0xauthor', + cid: 'cid-1', + link: undefined as string | undefined, + linkHeight: 0, + linkWidth: 0, + postCid: 'cid-1', + subplebbitAddress: 'music-posting.eth', + thumbnailUrl: undefined as string | undefined, +}; + +const renderMenu = async (initialEntry = '/mu', postMenu = basePostMenu) => { + await act(async () => { + root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(PostMenuDesktop, { postMenu } as any))); + }); +}; + +const openMenu = async () => { + const trigger = document.body.querySelector('[title="Post menu"]'); + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +describe('PostMenuDesktop', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.boardPath = 'mu'; + testState.hidden = false; + testState.mediaInfo = undefined; + testState.validUrl = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('copies direct links, content ids, and user ids from the desktop menu', async () => { + await renderMenu(); + + await openMenu(); + const copyLink = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_direct_link'); + await act(async () => { + copyLink?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.copyShareLinkMock).toHaveBeenCalledWith('mu', 'thread', 'cid-1'); + + await openMenu(); + const copyContentId = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_content_id'); + await act(async () => { + copyContentId?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.copyToClipboardMock).toHaveBeenCalledWith('cid-1'); + + await openMenu(); + const copyUserId = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_user_id'); + await act(async () => { + copyUserId?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.copyToClipboardMock).toHaveBeenCalledWith('0xauthor'); + }); + + it('toggles hide and unhide labels outside the thread root route', async () => { + await renderMenu('/mu'); + + await openMenu(); + const hideThread = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread'); + expect(hideThread).toBeTruthy(); + + await act(async () => { + hideThread?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.hideMock).toHaveBeenCalled(); + + testState.hidden = true; + await renderMenu('/mu'); + await openMenu(); + + const unhideThread = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'unhide_thread'); + expect(unhideThread).toBeTruthy(); + + await act(async () => { + unhideThread?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.unhideMock).toHaveBeenCalled(); + }); + + it('omits thread hiding on the thread root route and exposes image search entries for media posts', async () => { + testState.mediaInfo = { + type: 'image', + url: 'https://cdn.example/image.png', + }; + + await renderMenu('/mu/thread/cid-1', { + ...basePostMenu, + link: 'https://cdn.example/image.png', + }); + + await openMenu(); + + expect(Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread')).toBeUndefined(); + + const imageSearch = Array.from(document.body.querySelectorAll('[role="button"]')).find((node) => node.textContent?.includes('Image_search')); + expect(imageSearch).toBeTruthy(); + + await act(async () => { + imageSearch?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }); + + const hrefs = Array.from(document.body.querySelectorAll('a')).map((link) => link.getAttribute('href')); + expect(hrefs).toEqual( + expect.arrayContaining([ + 'https://lens.google.com/uploadbyurl?url=https://cdn.example/image.png', + 'https://www.yandex.com/images/search?img_url=https://cdn.example/image.png&rpt=imageview', + 'https://saucenao.com/search.php?url=https://cdn.example/image.png', + ]), + ); + }); +}); diff --git a/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx b/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx new file mode 100644 index 00000000..95eaa676 --- /dev/null +++ b/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx @@ -0,0 +1,235 @@ +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 PostMenuMobile from '../post-menu-mobile'; + +(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(() => ({ + copyShareLinkMock: vi.fn().mockResolvedValue(undefined), + copyToClipboardMock: vi.fn().mockResolvedValue(undefined), + hidden: false, + hideMock: vi.fn(), + mediaInfo: undefined as { thumbnail?: string; type?: string; url?: string } | undefined, + privileges: { + isAccountCommentAuthor: false, + isAccountMod: false, + }, + unhideMock: vi.fn(), + validUrl: true, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({})); + +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', () => ({ + FloatingFocusManager: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children), + autoUpdate: () => undefined, + flip: () => ({}), + offset: () => ({}), + shift: () => ({}), + useClick: () => ({}), + useDismiss: () => ({}), + useFloating: () => ({ + context: {}, + floatingStyles: {}, + refs: { + setFloating: () => undefined, + setReference: () => undefined, + }, + }), + useId: () => 'mobile-menu', + useInteractions: () => ({ + getFloatingProps: (props?: Record) => props || {}, + getReferenceProps: (props?: Record) => props || {}, + }), + useRole: () => ({}), +})); + +vi.mock('../../../../lib/utils/media-utils', () => ({ + getCommentMediaInfo: () => testState.mediaInfo, +})); + +vi.mock('../../../../lib/utils/url-utils', () => ({ + copyShareLinkToClipboard: testState.copyShareLinkMock, + isValidURL: () => testState.validUrl, +})); + +vi.mock('../../../../lib/utils/clipboard-utils', () => ({ + copyToClipboard: testState.copyToClipboardMock, +})); + +vi.mock('../../../../lib/utils/route-utils', () => ({ + getBoardPath: () => 'mu', +})); + +vi.mock('../../../../hooks/use-directories', () => ({ + useDirectories: () => [], +})); + +vi.mock('../../../../hooks/use-author-privileges', () => ({ + default: () => testState.privileges, +})); + +vi.mock('../../../../hooks/use-hide', () => ({ + default: () => ({ + hidden: testState.hidden, + hide: testState.hideMock, + unhide: testState.unhideMock, + }), +})); + +vi.mock('../../../../lib/utils/view-utils', () => ({ + isBoardView: (pathname: string) => /^\/[^/]+$/.test(pathname), + isPostPageView: (pathname: string) => pathname.includes('/thread/'), +})); + +vi.mock('../../../edit-menu/edit-menu', () => ({ + default: ({ post }: { post?: { cid?: string } }) => createElement('div', { 'data-testid': 'edit-menu' }, post?.cid || 'missing'), +})); + +let container: HTMLDivElement; +let root: Root; + +const basePostMenu = { + authorAddress: '0xauthor', + cid: 'cid-1', + deleted: false, + link: undefined as string | undefined, + linkHeight: 0, + linkWidth: 0, + parentCid: undefined as string | undefined, + postCid: 'cid-1', + removed: false, + subplebbitAddress: 'music-posting.eth', + thumbnailUrl: undefined as string | undefined, +}; + +const renderMenu = async (initialEntry = '/mu', postMenu = basePostMenu) => { + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [initialEntry] }, + createElement(PostMenuMobile, { + editMenuPost: { cid: postMenu.cid }, + postMenu, + } as any), + ), + ); + }); +}; + +const openMenu = async () => { + const trigger = document.body.querySelector('[title="Post menu"]'); + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +describe('PostMenuMobile', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.hidden = false; + testState.mediaInfo = undefined; + testState.privileges = { + isAccountCommentAuthor: false, + isAccountMod: false, + }; + testState.validUrl = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('opens the mobile menu, copies share metadata, and shows edit controls for privileged users', async () => { + testState.privileges = { + isAccountCommentAuthor: true, + isAccountMod: false, + }; + + await renderMenu('/mu'); + expect(document.body.querySelector('[data-testid="edit-menu"]')?.textContent).toBe('cid-1'); + + await openMenu(); + const copyLink = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_direct_link'); + await act(async () => { + copyLink?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.copyShareLinkMock).toHaveBeenCalledWith('mu', 'thread', 'cid-1'); + + await openMenu(); + const copyContentId = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_content_id'); + await act(async () => { + copyContentId?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.copyToClipboardMock).toHaveBeenCalledWith('cid-1'); + + await openMenu(); + const hideThread = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread'); + await act(async () => { + hideThread?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.hideMock).toHaveBeenCalled(); + }); + + it('omits thread hiding on the root thread route and suppresses the menu for deleted posts', async () => { + await renderMenu('/mu/thread/cid-1'); + await openMenu(); + expect(Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread')).toBeUndefined(); + + await renderMenu('/mu', { + ...basePostMenu, + deleted: true, + }); + + expect(document.body.querySelector('[title="Post menu"]')).toBeNull(); + }); + + it('renders image search targets for image posts in the mobile menu', async () => { + testState.mediaInfo = { + type: 'image', + url: 'https://cdn.example/image.png', + }; + + await renderMenu('/mu', { + ...basePostMenu, + link: 'https://cdn.example/image.png', + }); + + await openMenu(); + + const hrefs = Array.from(document.body.querySelectorAll('a')).map((link) => link.getAttribute('href')); + expect(hrefs).toEqual( + expect.arrayContaining([ + 'https://lens.google.com/uploadbyurl?url=https://cdn.example/image.png', + 'https://www.yandex.com/images/search?img_url=https://cdn.example/image.png&rpt=imageview', + 'https://saucenao.com/search.php?url=https://cdn.example/image.png', + ]), + ); + }); +}); diff --git a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx index ee232b4e..96088849 100644 --- a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx +++ b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx @@ -1,7 +1,7 @@ import { memo, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; -import { Comment } from '@bitsocialhq/bitsocial-react-hooks'; +import type { Comment } from '@bitsocialhq/bitsocial-react-hooks'; import { autoUpdate, flip, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react'; import styles from './post-menu-mobile.module.css'; import { getCommentMediaInfo } from '../../../lib/utils/media-utils'; diff --git a/src/components/reply-quote-preview/__tests__/reply-quote-preview.test.tsx b/src/components/reply-quote-preview/__tests__/reply-quote-preview.test.tsx new file mode 100644 index 00000000..42e6de80 --- /dev/null +++ b/src/components/reply-quote-preview/__tests__/reply-quote-preview.test.tsx @@ -0,0 +1,332 @@ +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 ReplyQuotePreview from '../reply-quote-preview'; + +(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; + }; + cid?: string; + number?: number; + subplebbitAddress?: string; +}; + +const testState = vi.hoisted(() => ({ + account: { + author: { + address: '0xme', + }, + }, + directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, + isMobile: false, + locationPath: '/mu/thread/thread-cid', + navigateMock: vi.fn(), + quoteAvailability: 'available' as 'available' | 'unavailable' | 'unresolved', + updateMock: vi.fn(), +})); + +vi.mock('react-router-dom', async () => { + const ReactModule = await vi.importActual('react'); + + return { + Link: ReactModule.forwardRef( + ( + { + children, + to, + ...props + }: { + children?: React.ReactNode; + to: string; + [key: string]: unknown; + }, + ref, + ) => ReactModule.createElement('a', { ...props, href: to, ref }, children), + ), + useLocation: () => ({ + pathname: testState.locationPath, + }), + useNavigate: () => testState.navigateMock, + }; +}); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, +})); + +vi.mock('@floating-ui/react', () => ({ + autoUpdate: vi.fn(), + offset: vi.fn(), + shift: vi.fn(), + size: vi.fn(), + useFloating: () => ({ + floatingStyles: { position: 'fixed' }, + refs: { + setFloating: () => undefined, + setReference: () => undefined, + }, + update: testState.updateMock, + }), +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, +})); + +vi.mock('../../../lib/utils/route-utils', () => ({ + getBoardPath: (address: string) => (address === 'music-posting.eth' ? 'mu' : address), +})); + +vi.mock('../../../lib/utils/quote-link-utils', () => ({ + formatQuoteNumber: (number?: number) => `>>${number ?? '?'}`, + getQuoteTargetAvailability: () => testState.quoteAvailability, + shouldShowFloatingQuotePreview: ({ + hoveredCid, + isUnavailable, + outOfViewCid, + quoteCid, + }: { + hoveredCid: string | null; + isUnavailable?: boolean; + outOfViewCid: string | null; + quoteCid?: string; + }) => Boolean(quoteCid && !isUnavailable && hoveredCid === quoteCid && outOfViewCid === quoteCid), +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../../views/post', () => ({ + Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post-preview' }, post?.cid), +})); + +let container: HTMLDivElement; +let root: Root; + +const renderPreview = async (props: Record) => { + await act(async () => { + root.render(createElement(ReplyQuotePreview, props as any)); + }); +}; + +const queryAnchorByText = (text: string) => Array.from(container.querySelectorAll('a')).find((anchor) => anchor.textContent === text) ?? null; + +const appendReplyElement = ({ + cid, + inViewport = true, + isThreadCard = false, + withHighlight = false, +}: { + cid: string; + inViewport?: boolean; + isThreadCard?: boolean; + withHighlight?: boolean; +}) => { + const element = document.createElement('div'); + element.dataset.cid = cid; + element.dataset.postCid = isThreadCard ? cid : 'thread-cid'; + if (withHighlight) { + element.classList.add('highlight'); + } + element.scrollIntoView = vi.fn(); + element.getBoundingClientRect = () => + ({ + bottom: inViewport ? 100 : window.innerHeight + 500, + left: 0, + right: 100, + top: inViewport ? 0 : -500, + }) as DOMRect; + document.body.appendChild(element); + return element; +}; + +describe('ReplyQuotePreview', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.account = { + author: { + address: '0xme', + }, + }; + testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; + testState.isMobile = false; + testState.locationPath = '/mu/thread/thread-cid'; + testState.navigateMock.mockReset(); + testState.quoteAvailability = 'available'; + testState.updateMock.mockReset(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.querySelectorAll('[data-cid]').forEach((node) => node.remove()); + document.querySelectorAll('.scroll-highlight').forEach((node) => node.remove()); + }); + + it('scrolls to an in-thread reply instead of navigating on desktop quotelinks', async () => { + const target = appendReplyElement({ cid: 'reply-cid' }); + const previous = document.createElement('div'); + previous.classList.add('scroll-highlight'); + document.body.appendChild(previous); + + await renderPreview({ + isQuotelinkReply: true, + quotelinkReply: { + cid: 'reply-cid', + number: 7, + subplebbitAddress: 'music-posting.eth', + }, + }); + + const link = queryAnchorByText('>>7'); + expect(link).toBeTruthy(); + + await act(async () => { + link?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(target.scrollIntoView as any).toHaveBeenCalledWith({ behavior: 'auto', block: 'center' }); + expect(target.classList.contains('scroll-highlight')).toBe(true); + expect(previous.classList.contains('scroll-highlight')).toBe(false); + expect(testState.navigateMock).not.toHaveBeenCalled(); + }); + + it('scrolls to the thread card top for OP quotes on the current desktop thread page', async () => { + const threadCard = appendReplyElement({ cid: 'thread-cid', isThreadCard: true }); + + await renderPreview({ + isOP: true, + isQuotelinkReply: true, + quotelinkReply: { + cid: 'thread-cid', + number: 1, + subplebbitAddress: 'music-posting.eth', + }, + }); + + const link = queryAnchorByText('>>1 (OP)'); + expect(link).toBeTruthy(); + + await act(async () => { + link?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(threadCard.scrollIntoView as any).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' }); + expect(testState.navigateMock).not.toHaveBeenCalled(); + }); + + it('handles desktop hover highlights and floating previews for quotelinks', async () => { + const inView = appendReplyElement({ cid: 'reply-cid', inViewport: true }); + + await renderPreview({ + isQuotelinkReply: true, + quotelinkReply: { + cid: 'reply-cid', + number: 9, + subplebbitAddress: 'music-posting.eth', + }, + }); + + const link = queryAnchorByText('>>9'); + expect(link).toBeTruthy(); + + await act(async () => { + link?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }); + + expect(inView.classList.contains('highlight')).toBe(true); + expect(document.querySelector('[data-testid="post-preview"]')).toBeNull(); + + await act(async () => { + link?.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); + }); + + expect(inView.classList.contains('highlight')).toBe(false); + + inView.remove(); + const outOfView = appendReplyElement({ cid: 'reply-cid', inViewport: false }); + + await act(async () => { + link?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }); + + expect(document.querySelector('[data-testid="post-preview"]')?.textContent).toBe('reply-cid'); + + await act(async () => { + link?.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); + }); + + expect(document.querySelector('[data-testid="post-preview"]')).toBeNull(); + outOfView.remove(); + }); + + it('renders unavailable desktop quotelinks without navigation and includes OP/You labels', async () => { + testState.quoteAvailability = 'unavailable'; + + await renderPreview({ + isOP: true, + isQuotelinkReply: true, + quotelinkReply: { + author: { + address: '0xme', + }, + cid: 'reply-cid', + number: 10, + subplebbitAddress: 'music-posting.eth', + }, + showTrailingBreak: false, + }); + + expect(container.textContent).toContain('>>10 (OP) (You)'); + expect(queryAnchorByText('>>10 (OP) (You)')).toBeNull(); + expect(container.querySelector('br')).toBeNull(); + }); + + it('renders mobile backlinks with a hash link that navigates to the target thread', async () => { + testState.isMobile = true; + + await renderPreview({ + backlinkReply: { + cid: 'reply-cid', + number: 5, + subplebbitAddress: 'music-posting.eth', + }, + isBacklinkReply: true, + }); + + expect(container.textContent).toContain('>>5'); + + const hashLink = queryAnchorByText(' #'); + expect(hashLink).toBeTruthy(); + + await act(async () => { + hashLink?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/thread/reply-cid'); + }); + + it('renders unresolved mobile quotelinks as plain text without a hash link', async () => { + testState.isMobile = true; + testState.quoteAvailability = 'unresolved'; + + await renderPreview({ + isQuotelinkReply: true, + quotelinkNumber: 42, + quotelinkReply: undefined, + }); + + expect(container.textContent).toContain('>>42'); + expect(Array.from(container.querySelectorAll('a')).some((anchor) => anchor.textContent?.includes('#'))).toBe(false); + }); +}); diff --git a/src/hooks/__tests__/use-state-string.test.tsx b/src/hooks/__tests__/use-state-string.test.tsx new file mode 100644 index 00000000..812891cf --- /dev/null +++ b/src/hooks/__tests__/use-state-string.test.tsx @@ -0,0 +1,142 @@ +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 useStateString, { useFeedStateString } from '../use-state-string'; + +(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(() => ({ + clientsStates: {} as Record, + subplebbit: undefined as + | { + publishingState?: string; + state?: string; + updatingState?: string; + } + | undefined, + subplebbitsStates: {} as Record, +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useClientsStates: () => ({ + states: testState.clientsStates, + }), + useSubplebbit: () => testState.subplebbit, + useSubplebbitsStates: () => ({ + states: testState.subplebbitsStates, + }), +})); + +vi.mock('lodash/debounce', () => ({ + default: unknown>(fn: T) => { + const wrapped = ((...args: Parameters) => fn(...args)) as T & { cancel: () => void }; + wrapped.cancel = () => undefined; + return wrapped; + }, +})); + +let container: HTMLDivElement; +let root: Root; +let latestValue: string | undefined; + +const StateStringHarness = ({ + value, +}: { + value: { + publishingState?: string; + state?: string; + updatingState?: string; + }; +}) => { + latestValue = useStateString(value); + return null; +}; + +const FeedStateStringHarness = ({ addresses }: { addresses?: string[] }) => { + latestValue = useFeedStateString(addresses); + return null; +}; + +describe('use-state-string', () => { + beforeEach(() => { + latestValue = undefined; + testState.clientsStates = {}; + testState.subplebbit = undefined; + testState.subplebbitsStates = {}; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('formats client state strings with normalized hostnames', () => { + testState.clientsStates = { + 'fetching-ipns': ['https://rpc.example.com/path', 'https://ipfs.io/api'], + 'resolving-address': ['https://ens.example.com'], + }; + + act(() => { + root.render(createElement(StateStringHarness, { value: { state: 'updating' } })); + }); + + expect(latestValue).toBe('Fetching IPNS from rpc.example.com, ipfs.io, resolving address from ens.example.com'); + }); + + it('falls back to publishing and updating states when no client states are available', () => { + act(() => { + root.render(createElement(StateStringHarness, { value: { publishingState: 'fetching-ipfs', state: 'publishing' } })); + }); + expect(latestValue).toBe('Downloading thread'); + + act(() => { + root.render(createElement(StateStringHarness, { value: { state: 'updating', updatingState: 'fetching-ipns' } })); + }); + expect(latestValue).toBe('Downloading board'); + }); + + it('sanitizes single-board feed state strings to board wording', () => { + testState.subplebbit = { + state: 'updating', + updatingState: 'fetching-ipfs', + }; + + act(() => { + root.render(createElement(FeedStateStringHarness, { addresses: ['music-posting.eth'] })); + }); + + expect(latestValue).toBe('Downloading board'); + }); + + it('aggregates multi-board feed states across address resolution, threads, and pages', () => { + testState.subplebbitsStates = { + 'fetching-ipfs': { + clientUrls: ['https://ipfs.io'], + subplebbitAddresses: ['music-posting.eth'], + }, + 'fetching-ipns': { + clientUrls: ['https://gateway.example.com'], + subplebbitAddresses: ['music-posting.eth', 'tech-posting.eth'], + }, + 'page-1': { + clientUrls: ['https://gateway.example.com', 'https://ipfs.io'], + subplebbitAddresses: ['music-posting.eth'], + }, + 'resolving-address': { + clientUrls: ['https://ens.example.com'], + subplebbitAddresses: ['music-posting.eth', 'tech-posting.eth'], + }, + }; + + act(() => { + root.render(createElement(FeedStateStringHarness, { addresses: ['music-posting.eth', 'tech-posting.eth'] })); + }); + + expect(latestValue).toBe('Resolving 2 addresses from ens.example.com, downloading 2 boards, 1 threads, 1 page from gateway.example.com, ipfs.io'); + }); +}); diff --git a/src/hooks/use-state-string.ts b/src/hooks/use-state-string.ts index b5900782..4a770c5c 100644 --- a/src/hooks/use-state-string.ts +++ b/src/hooks/use-state-string.ts @@ -68,7 +68,7 @@ const useStateString = (commentOrSubplebbit: CommentOrSubplebbit): string | unde if (commentOrSubplebbit?.publishingState && commentOrSubplebbit?.publishingState !== 'stopped' && commentOrSubplebbit?.publishingState !== 'succeeded') { stateString = commentOrSubplebbit.publishingState; } else if (commentOrSubplebbit?.updatingState !== 'stopped' && commentOrSubplebbit?.updatingState !== 'succeeded') { - stateString = commentOrSubplebbit.updatingState; + stateString = commentOrSubplebbit?.updatingState; } if (stateString) { stateString = stateString diff --git a/src/lib/utils/post-menu-props.ts b/src/lib/utils/post-menu-props.ts index 99ecdc4f..350c83d5 100644 --- a/src/lib/utils/post-menu-props.ts +++ b/src/lib/utils/post-menu-props.ts @@ -1,4 +1,4 @@ -import { Comment } from '@bitsocialhq/bitsocial-react-hooks'; +import type { Comment } from '@bitsocialhq/bitsocial-react-hooks'; export type PostMenuProps = { cid?: string;