mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test: expand markdown and post menu coverage
Adds focused regression coverage for quote previews, markdown link rendering, embed toggles, and desktop/mobile post-menu actions. It also fixes the undefined feed-state crash in use-state-string.ts and removes unnecessary runtime imports with import type.
This commit is contained in:
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
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<HTMLIFrameElement>('iframe')?.getAttribute('src')).toBe('https://www.youtube.com/embed/video123');
|
||||
|
||||
await renderEmbed('https://www.twitch.tv/videos/987654321');
|
||||
|
||||
expect(container.querySelector<HTMLIFrameElement>('iframe')?.getAttribute('src')).toContain('video=987654321');
|
||||
expect(container.querySelector<HTMLIFrameElement>('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<HTMLIFrameElement>('iframe')?.getAttribute('srcdoc')).toContain('twitter-tweet');
|
||||
|
||||
await renderEmbed('https://www.reddit.com/r/test/comments/abc123/example/');
|
||||
|
||||
expect(container.querySelector<HTMLIFrameElement>('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);
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
type TestComment = {
|
||||
cid?: string;
|
||||
number?: number;
|
||||
};
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
comments: {} as Record<string, TestComment>,
|
||||
embeddableHosts: new Set<string>(),
|
||||
internalPathByHref: {} as Record<string, string | null>,
|
||||
isMobile: false,
|
||||
mediaInfoByHref: {} as Record<string, { thumbnail?: string; type: string; url: string }>,
|
||||
numberToCid: {} as Record<string, Record<number, string>>,
|
||||
unavailableCids: new Set<string>(),
|
||||
}));
|
||||
|
||||
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<string, unknown>) => props || {},
|
||||
getReferenceProps: (props?: Record<string, unknown>) => 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<string>();
|
||||
testState.internalPathByHref = {};
|
||||
testState.isMobile = false;
|
||||
testState.mediaInfoByHref = {};
|
||||
testState.numberToCid = {};
|
||||
testState.unavailableCids = new Set<string>();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
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<string, unknown>) => props || {},
|
||||
getReferenceProps: (props?: Record<string, unknown>) => 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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
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<string, unknown>) => props || {},
|
||||
getReferenceProps: (props?: Record<string, unknown>) => 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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
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<typeof import('react')>('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<string, unknown>) => {
|
||||
await act(async () => {
|
||||
root.render(createElement(ReplyQuotePreview, props as any));
|
||||
});
|
||||
};
|
||||
|
||||
const queryAnchorByText = (text: string) => Array.from(container.querySelectorAll<HTMLAnchorElement>('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);
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
clientsStates: {} as Record<string, string[]>,
|
||||
subplebbit: undefined as
|
||||
| {
|
||||
publishingState?: string;
|
||||
state?: string;
|
||||
updatingState?: string;
|
||||
}
|
||||
| undefined,
|
||||
subplebbitsStates: {} as Record<string, { clientUrls: string[]; subplebbitAddresses: string[] }>,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useClientsStates: () => ({
|
||||
states: testState.clientsStates,
|
||||
}),
|
||||
useSubplebbit: () => testState.subplebbit,
|
||||
useSubplebbitsStates: () => ({
|
||||
states: testState.subplebbitsStates,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('lodash/debounce', () => ({
|
||||
default: <T extends (...args: any[]) => unknown>(fn: T) => {
|
||||
const wrapped = ((...args: Parameters<T>) => 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Comment } from '@bitsocialhq/bitsocial-react-hooks';
|
||||
import type { Comment } from '@bitsocialhq/bitsocial-react-hooks';
|
||||
|
||||
export type PostMenuProps = {
|
||||
cid?: string;
|
||||
|
||||
Reference in New Issue
Block a user